diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 71e576e5c7e4..3dacaf2a92a9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -10,32 +10,53 @@ # # Keep entries sorted alphabetically. github:adityavardhansharma +github:arhxam +github:bil0000 github:binbandit +github:Brechard github:chrisdeeming github:chuks-qua github:cursoragent +github:D3OXY +github:eggfriedrice24 +github:extoci github:gbarros-dev github:gfsaaser24 github:github-actions[bot] +github:gsimone +github:GuilhermeVieiraDev github:hwanseoc +github:jakeleventhal github:jamesx0416 +github:jappyjan github:jasonLaster github:JoeEverest +github:justsomelegs +github:kridaydave +github:lnieuwenhuis +github:Lucenx9 +github:mackinleysmith github:maria-rcks +github:mwolson github:nmggithub github:Noojuno github:notkainoa github:PatrickBauer +github:pc-style +github:PixPMusic +github:PollyGlot +github:RakshithBhat03 github:realAhmedRoach +github:Rishet11 github:saphid +github:sethwebster github:shiroyasha9 +github:shivamhwp github:StiensWout +github:SunkenInTime +github:tarik02 +github:tris203 +github:UtkarshUsername github:Yash-Singh1 -github:eggfriedrice24 +github:yashranaway github:Ymit24 -github:shivamhwp -github:jappyjan -github:justsomelegs -github:UtkarshUsername -github:SunkenInTime -github:bil0000 diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg deleted file mode 100644 index dbeb594a09da..000000000000 --- a/.github/pr-assets/6424-after.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg deleted file mode 100644 index 6b365bad6e69..000000000000 --- a/.github/pr-assets/6424-before.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/.github/pr-assets/6503-after.svg b/.github/pr-assets/6503-after.svg deleted file mode 100644 index db1c9cb54065..000000000000 --- a/.github/pr-assets/6503-after.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 052a8c20cf78..731707eed4d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,14 @@ jobs: !/.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: @@ -31,11 +39,6 @@ jobs: cache: true run-install: true - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -45,9 +48,6 @@ jobs: - name: Typecheck run: vpr typecheck - - name: Check resource monitor formatting - run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check - - name: Build desktop pipeline run: vp run build:desktop @@ -57,6 +57,11 @@ jobs: grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs grep -n "__clerk_internal_electron_passkeys" apps/desktop/dist-electron/preload.cjs + # 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 @@ -77,20 +82,65 @@ jobs: cache: true run-install: true - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + - 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 test + run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} - - name: Publish transfer budget report + # 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" @@ -99,7 +149,7 @@ jobs: fi - name: Upload thread transfer result - if: always() + if: always() && steps.transfer_budget.outputs.present == 'true' uses: actions/upload-artifact@v7 with: name: thread-transfer-results @@ -107,11 +157,116 @@ jobs: 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 resource monitor formatting + run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check + - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + # 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: diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml new file mode 100644 index 000000000000..6d1264aa7b7a --- /dev/null +++ b/.github/workflows/desktop-macos-preview.yml @@ -0,0 +1,165 @@ +name: Desktop macOS Preview + +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: desktop-macos-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + name: Build macOS Apple Silicon preview + if: >- + 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 + 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" + + - id: upload + 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 + + - name: Comment download link + uses: actions/github-script@v8 + env: + ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + DMG_NAME: ${{ steps.build.outputs.dmg_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PREVIEW_VERSION: ${{ steps.version.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) { + core.info("Skipping the outdated macOS preview comment."); + return; + } + + const marker = ""; + const body = [ + marker, + "### macOS preview", + "", + `[Download Apple Silicon DMG](${process.env.ARTIFACT_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}`, + "```", + "", + "The download requires GitHub access and expires after 7 days.", + ].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/release.yml b/.github/workflows/release.yml index 6abd702bf889..b8a2fab33ee4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,8 @@ on: - "v*.*.*" - "!v*-nightly.*" schedule: - - cron: "0 */3 * * *" + # Off minute zero: GitHub delays scheduled runs most at the top of the hour. + - cron: "7 */3 * * *" workflow_dispatch: inputs: channel: @@ -22,6 +23,17 @@ on: 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. Queued nightlies with no new commits skip via check_changes. +concurrency: + group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} + cancel-in-progress: false + queue: max + permissions: contents: read id-token: none @@ -100,9 +112,6 @@ jobs: cache: true run-install: true - - name: Ensure Electron runtime is installed - run: vp run --filter @t3tools/desktop ensure:electron - - id: release_meta name: Resolve release version shell: bash @@ -157,6 +166,40 @@ jobs: 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 + + 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 @@ -166,18 +209,16 @@ jobs: - name: Test run: vp run test - - id: previous_tag - name: Resolve previous release tag - run: | - node scripts/resolve-previous-release-tag.ts \ - --channel "${{ steps.release_meta.outputs.release_channel }}" \ - --current-tag "${{ steps.release_meta.outputs.tag }}" \ - --github-output - relay_public_config: name: Resolve T3 Connect public config - needs: preflight - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + # Consumes only the commit SHA, not preflight's resolved version, so it runs + # alongside preflight instead of after it. The condition mirrors preflight's: + # check_changes is skipped on non-schedule events (skipped is neither failure + # nor success, so success() would be wrong here). + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 environment: @@ -199,7 +240,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ github.sha }} sparse-checkout: | /* !/.repos/ @@ -272,15 +313,19 @@ jobs: # 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) - needs: [preflight] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + # Same gating as relay_public_config: only the commit SHA is needed, so this + # runs alongside preflight. See the condition comment there. + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ github.sha }} sparse-checkout: | /* !/.repos/ @@ -385,14 +430,34 @@ jobs: uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/desktop... - - --filter=t3... - - --filter=@t3tools/scripts... + 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') }} + + - 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/${{ 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: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.rust_target }} @@ -518,6 +583,7 @@ jobs: - name: Build desktop artifact shell: bash env: + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} @@ -664,8 +730,8 @@ jobs: publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} + 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: @@ -714,9 +780,8 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - name: Build web package - run: vp run --filter @t3tools/web build - + # 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 diff --git a/.gitignore b/.gitignore index 07793efe9b52..57262578a786 100644 --- a/.gitignore +++ b/.gitignore @@ -25,11 +25,13 @@ __screenshots__/ squashfs-root/ .vercel .gstack/ +.plans/ dist-electron/ .electron-runtime/ .showcase/ apps/mobile/.showcase/ artifacts/app-store/screenshots/ +.github/pr-assets/ native/**/target/ node_modules/ .alchemy/ diff --git a/.macroscope/approvability.md b/.macroscope/approvability.md new file mode 100644 index 000000000000..cfea7fdd57c2 --- /dev/null +++ b/.macroscope/approvability.md @@ -0,0 +1 @@ +Use Macroscope's default approvability criteria. diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index 542e9028d36f..b76d56d45dbc 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -68,6 +68,7 @@ Review changed TypeScript and directly affected call sites for the conventions b - Export direct schema predicates such as `export const isFoo = Schema.is(Foo)`. Flag a private `Schema.is` constant wrapped by a redundant function with the same signature. - Do not introduce a large `switch` or lookup table in an error's `message` getter to model failures that deserve separate error classes. - Catch statically known tagged failures with `Effect.catchTags({ ... })`, including when handling only one tag. Do not use `catchIf` with a schema predicate merely to recover one or more known `_tag` variants, and do not use `catchTag`. `Effect.catch` is appropriate when the entire error channel is intentionally handled; `catchIf` remains appropriate for genuinely structural predicates such as inspecting an underlying platform error code. +- For startup reconciliation that repairs multiple independent entities, preserve interruption rather than reducing it to a warning. Retry a transient per-entity repair before readiness, then isolate a persistent failure so one bad entity cannot abort global startup or prevent later entities from being repaired. Require tests for both the retry-success path and persistent-failure continuation. - Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`, including curried aliases used once with `mapError`. Construct the error at the failure boundary so its attributes and cause remain visible. Keep a mapper only when it performs real normalization, passes through existing domain errors, or adds reusable context/control flow. - When a reusable error-to-error translation clearly belongs to the target error type, prefer a descriptive static factory on that error class over a detached production-side switch. Do not force a static method for one-off inline mappings. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 8ec720742759..c2f2c57c1cf2 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -47,6 +47,9 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - light-only declarations use `@variant light`; - raw `.dark` should remain only in the `dark` and `light` custom-variant definitions. - Preserve custom themes and runtime token bridges. Removing a variable or selector is safe only when all runtime, inspector, generated, and theme-palette consumers are accounted for. +- Contrast and accessibility settings that target app chrome must derive from semantic color tokens. Do not apply `filter` to `html`, `body`, or the app root: it also changes user media, previews, terminals, glass backdrop ownership, and view-transition snapshots. +- Preserve alpha and surface ownership when deriving contrast tokens. Soften translucent borders and inputs toward transparent rather than an opaque canvas, use a modest semantic-foreground mix for stronger borders, and adjust card, popover, accent, secondary, and message foregrounds against their own surfaces when the base foreground changes. +- Runtime-adjusted roles must be ordinary custom properties shared by the Tailwind bridge, global CSS, imperative style strings, and bridge snapshots sent to other renderers. Audit literal `var(--foreground)`, `var(--border)`, and related role reads so headings, markdown chrome, menus, previews, and utilities do not split into adjusted and unadjusted colors. - Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient. - Flag malformed or empty emitted selectors such as empty `:is()` or `:not(:is())`, selector branches that can never match their own class attribute, and transformations that silently drop the intended rule. - Prefer source-level logic over clever selectors when behavior depends on consumer-provided class strings. Preserve `MenuPopup`'s current defaulting contract: a string `className` containing a `w-*`, `min-w-*`, or `max-w-*` utility after variant prefixes are stripped suppresses `min-w-32`; a string without one and a functional/non-string `className` keep the default. Arbitrary width values count as width utilities, and the consumer class must be merged last so it retains control. Do not replace this with a raw class-attribute substring selector. @@ -67,6 +70,13 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - Do not treat a screenshot as proof of keyboard, overflow, scrollbar, responsive, or runtime-theme behavior. Pair visual evidence with source, computed-style, emitted-CSS, or interaction checks as appropriate. - Be alert to shared primitive color indirection. When a primitive routes icon color through a CSS variable, ensure migrated contextual icons retain their intended tone, including pressed and disabled states. +## Environment routing in shared renderers + +- A shared renderer that performs an environment-scoped action — a server RPC such as opening or revealing a file, an environment-gated capability check, or an OS-derived label — must resolve its target environment from explicit scope: the bound thread's `environmentId`, or an `environmentId` prop threaded from the owning surface. Never let it silently fall back to the globally active environment. Multi-environment surfaces (pull request panels, review annotations, cross-environment listings) can render content from environment B while environment A is active; a silent fallback sends B's paths to A's server and presents A's platform wording. +- When a call site cannot supply an explicit environment scope, suppress the environment-scoped actions at that call site rather than guessing. A hidden menu item is correct; an item that targets the wrong server is a concrete finding. +- Capability gating, action dispatch, and user-facing labels must all read from the same environment's server config that the action will execute against. Flag a renderer whose label derives from one environment while its RPC targets another. +- Flag new call sites of shared markdown, chip, or menu renderers that trigger environment actions without passing explicit scope, and flag new environment-action props whose default reintroduces an active-environment fallback. + ## Change discipline - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. diff --git a/.plans/01-shared-model-normalization.md b/.plans/01-shared-model-normalization.md deleted file mode 100644 index d38c41643fa9..000000000000 --- a/.plans/01-shared-model-normalization.md +++ /dev/null @@ -1,49 +0,0 @@ -# Plan: Centralize Model Normalization in Contracts - -## Summary - -Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth. - -## Motivation - -- Removes duplicated logic between: - - `apps/desktop/src/codexAppServerManager.ts` - - `apps/renderer/src/model-logic.ts` -- Prevents behavior drift when model aliases/defaults are updated. - -## Scope - -- Add shared model utilities to contracts. -- Update desktop and renderer to consume shared utilities. -- Keep renderer-specific display options in renderer. - -## Proposed Changes - -1. Add `packages/contracts/src/model.ts` with: - - Canonical model list - - Alias map - - `normalizeModelSlug` - - `resolveModelSlug` - - `DEFAULT_MODEL` -2. Export model utilities from `packages/contracts/src/index.ts`. -3. Update `apps/desktop/src/codexAppServerManager.ts` to replace local alias map/helper. -4. Update `apps/renderer/src/model-logic.ts` to wrap or re-export shared functions. -5. Update tests: - - Move/duplicate normalization tests to contracts. - - Keep renderer tests focused on renderer-only behavior. - -## Risks - -- Desktop/renderer may currently rely on slightly different fallback behavior. -- Import graph must avoid bundling issues for Electron main/preload. - -## Validation - -- `bun run test` -- `bun run typecheck` -- Manual check that model selection and session start still send expected model slug. - -## Done Criteria - -- No duplicated alias/default map in desktop and renderer. -- Shared model utilities are contract-tested. diff --git a/.plans/02-typed-ipc-boundaries.md b/.plans/02-typed-ipc-boundaries.md deleted file mode 100644 index fac5b1fc2e21..000000000000 --- a/.plans/02-typed-ipc-boundaries.md +++ /dev/null @@ -1,44 +0,0 @@ -# Plan: Strengthen Typed IPC Boundaries in Main Process - -## Summary - -Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers. - -## Motivation - -- `apps/desktop/src/main.ts` currently uses casts like `payload as Parameters<...>`. -- Casts can hide contract breakages until runtime. - -## Scope - -- Desktop main process IPC registration. -- Optional shared helper for handler registration. - -## Proposed Changes - -1. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to: - - Parse payload(s) with Zod schemas - - Standardize typed handler signatures -2. Refactor provider IPC handlers in `apps/desktop/src/main.ts` to use: - - `providerSessionStartInputSchema.parse` - - `providerSendTurnInputSchema.parse` - - `providerInterruptTurnInputSchema.parse` - - `providerStopSessionInputSchema.parse` -3. Apply same pattern to agent/terminal handlers where possible. -4. Add tests for handler parsing failure paths (invalid payloads). - -## Risks - -- Refactor can subtly change IPC error shape/messages. -- Helper abstraction should stay simple and not obscure control flow. - -## Validation - -- `bun run test` -- `bun run typecheck` -- Manual invalid payload check from renderer/devtools to confirm fast failure. - -## Done Criteria - -- No provider handler uses `payload as Parameters<...>`. -- All IPC entrypoints parse unknown payloads at boundary. diff --git a/.plans/03-split-codex-app-server-manager.md b/.plans/03-split-codex-app-server-manager.md deleted file mode 100644 index 4f7fadb4314b..000000000000 --- a/.plans/03-split-codex-app-server-manager.md +++ /dev/null @@ -1,48 +0,0 @@ -# Plan: Decompose CodexAppServerManager - -## Summary - -Split `CodexAppServerManager` into smaller modules with clear responsibilities. - -## Motivation - -- `apps/desktop/src/codexAppServerManager.ts` is large and mixes: - - Process lifecycle - - JSON-RPC parsing/routing - - Session state transitions - - Event emission -- This increases regression risk and slows changes. - -## Scope - -- Desktop provider internals only. -- Keep external behavior/API stable. - -## Proposed Changes - -1. Extract modules: - - `codex/processLifecycle.ts` - - `codex/jsonrpcRouter.ts` - - `codex/sessionState.ts` - - `codex/parsing.ts` -2. Keep `CodexAppServerManager` as thin orchestrator/facade. -3. Move pure helpers (`classifyCodexStderrLine`, route parsing) into unit-testable files. -4. Add targeted unit tests for: - - Message classification - - Request/notification/response routing - - Session state transitions - -## Risks - -- Reordering event handling can change behavior. -- Must preserve pending request timeout/cancellation semantics. - -## Validation - -- Existing tests pass. -- Add module-level tests for parsing and transition logic. - -## Done Criteria - -- Main manager file materially smaller and orchestration-focused. -- Core protocol/state logic covered by focused tests. diff --git a/.plans/04-split-chatview-component.md b/.plans/04-split-chatview-component.md deleted file mode 100644 index abf30c04f898..000000000000 --- a/.plans/04-split-chatview-component.md +++ /dev/null @@ -1,47 +0,0 @@ -# Plan: Split ChatView into Smaller UI/Logic Units - -## Summary - -Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. - -## Motivation - -- `apps/renderer/src/components/ChatView.tsx` is large and handles: - - Session orchestration - - Send/interrupt actions - - Timeline rendering - - Header/status UI - - Composer UI -- Hard to test and maintain as one component. - -## Scope - -- Renderer component boundaries and hooks. -- Keep visual behavior unchanged. - -## Proposed Changes - -1. Create hook: `apps/renderer/src/hooks/useChatSession.ts` - - `ensureSession` - - `sendTurn` - - `interruptTurn` -2. Split presentational components: - - `components/chat/ThreadHeader.tsx` - - `components/chat/MessageTimeline.tsx` - - `components/chat/ComposerBar.tsx` -3. Keep `ChatView.tsx` as container wiring store + hook + child components. -4. Add focused tests for hook behavior (error handling, session reuse). - -## Risks - -- Refactor can break subtle UI interactions (auto-scroll, menu close, keyboard send). - -## Validation - -- `bun run test` -- Manual smoke: send, stream, interrupt, model switch. - -## Done Criteria - -- `ChatView.tsx` significantly reduced and easier to scan. -- Session logic isolated from rendering. diff --git a/.plans/05-zod-persisted-state-validation.md b/.plans/05-zod-persisted-state-validation.md deleted file mode 100644 index 869da86796b3..000000000000 --- a/.plans/05-zod-persisted-state-validation.md +++ /dev/null @@ -1,41 +0,0 @@ -# Plan: Move Renderer Persisted-State Validation to Zod - -## Summary - -Use explicit Zod schemas for localStorage state parsing and migration. - -## Motivation - -- `apps/renderer/src/store.ts` has large manual sanitize functions. -- Manual type guards are verbose and easier to get wrong during schema evolution. - -## Scope - -- Renderer state hydration/persistence path. -- No backend/protocol changes. - -## Proposed Changes - -1. Add schema module: `apps/renderer/src/persistenceSchema.ts` - - Persisted payload versions (`v1`, `v2`) - - Thread/message/project schemas -2. Replace `sanitizeProjects/sanitizeThreads/sanitizeMessages` with schema parsing + transforms. -3. Keep migration logic explicit (legacy model migration and key migration). -4. Add tests for: - - Invalid payload fallback to initial state - - Legacy payload migration - - Unknown thread/project references filtered - -## Risks - -- Overly strict schemas could drop valid historical data unexpectedly. - -## Validation - -- Unit tests for migration/hydration. -- Manual reload test with existing localStorage data. - -## Done Criteria - -- Store hydration logic is schema-driven. -- Migration behavior is tested and documented. diff --git a/.plans/06-provider-logstream-lifecycle.md b/.plans/06-provider-logstream-lifecycle.md deleted file mode 100644 index 0a92de36f72d..000000000000 --- a/.plans/06-provider-logstream-lifecycle.md +++ /dev/null @@ -1,38 +0,0 @@ -# Plan: Add Provider Log Stream Lifecycle Management - -## Summary - -Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely. - -## Motivation - -- `apps/desktop/src/providerManager.ts` opens a write stream in constructor. -- Stream lifecycle is not explicit on shutdown. - -## Scope - -- Desktop provider logging behavior. -- App shutdown integration. - -## Proposed Changes - -1. Add explicit `dispose()` on `ProviderManager`: - - Remove event listeners - - End/close log stream -2. Call `providerManager.dispose()` from app shutdown path in `apps/desktop/src/main.ts`. -3. Optional: change log format to JSON lines with stable fields. -4. Optional: per-session log files under `.logs/providers/`. - -## Risks - -- Improper close sequencing may lose final log lines. - -## Validation - -- Manual run/quit cycle to ensure no open handle warnings. -- Confirm logs flush on quit and file descriptors are not leaked. - -## Done Criteria - -- ProviderManager owns complete log stream lifecycle. -- Shutdown path explicitly disposes provider resources. diff --git a/.plans/07-ci-quality-gates.md b/.plans/07-ci-quality-gates.md deleted file mode 100644 index ff27a9dbd95e..000000000000 --- a/.plans/07-ci-quality-gates.md +++ /dev/null @@ -1,41 +0,0 @@ -# Plan: Add CI Workflow for Core Quality Gates - -## Summary - -Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs. - -## Motivation - -- Repository currently has no CI workflow files. -- Quality checks are only local/manual. - -## Scope - -- `.github/workflows/ci.yml` -- Bun + Turbo setup in CI. - -## Proposed Changes - -1. Add `ci.yml` with jobs: - - Setup Bun and Node environment - - Install deps - - `bun run lint` - - `bun run typecheck` - - `bun run test` -2. Add separate optional job for `bun run smoke-test` (desktop/Electron). -3. Configure caching for Bun/Turbo as appropriate. - -## Risks - -- Smoke test may be flaky in headless CI environments. -- CI runtime can grow if caching is misconfigured. - -## Validation - -- Verify workflow runs on a branch PR. -- Ensure failures surface clearly by job name. - -## Done Criteria - -- CI blocks regressions in lint/typecheck/test. -- Workflow docs added to README. diff --git a/.plans/08-precommit-format-and-lint.md b/.plans/08-precommit-format-and-lint.md deleted file mode 100644 index a919ac07e471..000000000000 --- a/.plans/08-precommit-format-and-lint.md +++ /dev/null @@ -1,39 +0,0 @@ -# Plan: Add Pre-Commit Formatting/Lint Hooks - -## Summary - -Introduce pre-commit automation so formatting and basic lint checks happen before commits. - -## Motivation - -- Current lint failures include formatting-only issues. -- Shift-left feedback reduces noisy CI failures and cleanup churn. - -## Scope - -- Root tooling config and package scripts. -- No runtime code changes. - -## Proposed Changes - -1. Add hook tooling (e.g. Husky + lint-staged or Lefthook). -2. Configure staged-file tasks: - - `biome format --write` - - `biome check` -3. Add setup docs in README. -4. Keep checks fast to avoid developer friction. - -## Risks - -- Slow hooks can frustrate contributors and be bypassed. -- Need to ensure compatibility with Bun workspace setup. - -## Validation - -- Create sample staged changes and verify hook behavior. -- Confirm formatting fixes are applied automatically. - -## Done Criteria - -- Pre-commit hook installed and documented. -- Formatting-only lint failures drop significantly. diff --git a/.plans/09-event-state-test-expansion.md b/.plans/09-event-state-test-expansion.md deleted file mode 100644 index 35db64bc0e46..000000000000 --- a/.plans/09-event-state-test-expansion.md +++ /dev/null @@ -1,42 +0,0 @@ -# Plan: Expand Event/State Transition Test Coverage - -## Summary - -Add focused tests for renderer event handling and session evolution logic. - -## Motivation - -- Core behavior is event-driven and stateful. -- Existing renderer tests cover only a subset of timeline/model behavior. - -## Scope - -- `apps/renderer/src/session-logic.test.ts` -- Optional reducer tests for `apps/renderer/src/store.ts`. - -## Proposed Changes - -1. Add tests for `evolveSession`: - - `thread/started` - - `turn/started` - - `turn/completed` success/failure - - error/session closed events -2. Add tests for `applyEventToMessages`: - - start/delta/completed flow - - out-of-order event cases - - turn completion clearing streaming flags -3. Add reducer integration tests for `APPLY_EVENT`. - -## Risks - -- Tests may be brittle if event payload fixtures are too coupled to implementation details. - -## Validation - -- `bun run test` -- Ensure new tests remain deterministic and fast. - -## Done Criteria - -- High-risk event transitions are covered by unit tests. -- Regressions in stream assembly/session status are caught quickly. diff --git a/.plans/10-unify-process-session-abstraction.md b/.plans/10-unify-process-session-abstraction.md deleted file mode 100644 index 72f5d618b935..000000000000 --- a/.plans/10-unify-process-session-abstraction.md +++ /dev/null @@ -1,42 +0,0 @@ -# Plan: Unify Process and PTY Session Abstractions in ProcessManager - -## Summary - -Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes. - -## Motivation - -- `apps/desktop/src/processManager.ts` maintains parallel maps and branch-heavy logic. -- New execution backends/providers will multiply complexity. - -## Scope - -- Desktop process execution internals. -- Preserve public `ProcessManager` API. - -## Proposed Changes - -1. Introduce internal interface (e.g. `RuntimeSession`): - - `write(data)` - - `kill()` - - lifecycle/output event hooks -2. Implement: - - `ChildProcessSession` - - `PtySession` -3. Replace dual maps with one `Map`. -4. Keep output/exit event contract unchanged. -5. Add tests for both implementations. - -## Risks - -- PTY behavior differs by platform; abstraction must not hide required differences. - -## Validation - -- Existing `processManager.test.ts` passes. -- Add PTY-path tests where feasible. - -## Done Criteria - -- Manager no longer branches per backend in `write/kill/killAll`. -- Session backends are independently testable. diff --git a/.plans/11-effect.md b/.plans/11-effect.md deleted file mode 100644 index 66521c20aa4a..000000000000 --- a/.plans/11-effect.md +++ /dev/null @@ -1,40 +0,0 @@ -PR 1: Service contracts + error taxonomy -Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs. -Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each). -No behavior change yet, just interfaces and compile-time wiring points. -PR 2: CheckpointStore Effect adapter -Wrap current filesystemCheckpointStore behind CheckpointStoreLive (adapter). -Map all thrown/Promise errors to tagged errors. -Add service tests proving parity for isGitRepository, capture, restore, diff, prune. -PR 3: CodexService Effect adapter -Wrap current CodexAppServerManager behind CodexServiceLive (adapter). -Convert public API to Effect return types with typed errors. -Preserve existing EventEmitter internally for now, but expose Effect-friendly subscribe API. -PR 4: ProviderService Effect adapter -Wrap current ProviderManager behind ProviderServiceLive (adapter). -Provider methods become Effect methods with typed errors. -Route emitted provider events through an Effect PubSub surface. -PR 5: wsServer migration to Effect services -Stop instantiating provider/codex classes directly in wsServer. -Resolve ProviderService (and related services) from one runtime/layer graph. -Keep WS contract behavior identical. -PR 6: Native CheckpointStore implementation -Refactor checkpoint internals from Promise/throws to native Effect. -Replace ad-hoc locking with Effect concurrency primitive (keyed lock/semaphore/queue). -Keep adapter tests plus new failure-path tests. -PR 7: Codex transport/RPC core as native Effect -Split codex into scoped process layer + RPC request/response layer + session registry. -Replace timeout/pending maps with Deferred + Effect timeout/finalizer semantics. -Keep protocol behavior and ordering guarantees. -PR 8: Codex protocol decoding hardening -Replace ad-hoc unknown parsing with runtime schema decoding for inbound/outbound protocol shapes. -Map decode failures to typed tagged errors (with root cause). -Add regression tests for malformed/partial protocol messages. -PR 9: Native ProviderService orchestration -Rebuild provider logic in Effect using CodexService + CheckpointStore dependencies. -Move event fanout, checkpoint capture/revert orchestration, thread-log routing to Effect state/services. -Remove throw-based flow entirely from provider path. -PR 10: Cleanup + deprecation removal -Remove legacy class implementations/adapters once parity is proven. -Finalize layer composition and startup graph docs. -Add architecture notes for service boundaries and error model. diff --git a/.plans/12-effect-new.md b/.plans/12-effect-new.md deleted file mode 100644 index 3d87049f8bae..000000000000 --- a/.plans/12-effect-new.md +++ /dev/null @@ -1,67 +0,0 @@ -# Effect Migration Plan (From Current State) - -Current status summary: - -- Service contracts, typed errors, and most checkpoint/persistence services exist. -- `ProviderServiceLive` is already native orchestration (not a thin adapter). -- Production server path still uses legacy `ProviderManager`/`FilesystemCheckpointStore`. -- Checkpoint flow now avoids snapshot re-sync and is write-time driven. - -## PR 1: Wire Provider/Checkpoint Effect Stack Into `wsServer` - -- Build one runtime layer graph for provider + checkpoint + persistence + orchestration. -- Resolve `ProviderService` from runtime in `wsServer`. -- Replace `ProviderManager` method calls in WS handlers with `ProviderService` calls. -- Forward provider events by subscribing to `ProviderService.subscribeToEvents`. -- Keep WS method/push payloads identical. - -## PR 2: Runtime Composition + Startup Ownership - -- Create/centralize `AppLive` composition for server startup. -- Ensure outer runtime provides Node/platform services once. -- Ensure migrations run at startup via scoped/layer startup path. -- Remove ad-hoc service initialization in request-time paths. - -## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants - -- Add explicit checkpoint session cleanup on `stopSession` / `stopAll`. -- Remove per-session lock/cwd map leaks. -- Keep strict invariant model: - - root checkpoint created at session initialization before agent modifications - - each completed turn captures filesystem checkpoint and persists metadata - - no after-the-fact metadata rebuild/sync -- Add tests for lifecycle cleanup and invariant-failure surfaces. - -## PR 4: Provider Event Stream Hardening (Without Extra Service Fragmentation) - -- Keep `ProviderService` as the public event surface. -- Internally move callback fanout to Effect concurrency primitives (`Queue`/`PubSub`) for ordering/backpressure control. -- Keep API as `subscribeToEvents` unless we explicitly choose stream API later. -- Add tests for ordering and subscriber isolation under load. - -## PR 5: Codex Runtime Split (Scoped Effect Core) - -- Extract `CodexAppServerManager` responsibilities into Effect-native layers: - - scoped process lifecycle - - RPC request/response + pending map via `Deferred` - - session registry/state -- Keep `CodexAdapter` contract stable while swapping internals. -- Preserve protocol behavior and timeout semantics. - -## PR 6: Codex Protocol Decode Hardening - -- Replace ad-hoc unknown parsing with runtime schema decode. -- Map decode failures to typed tagged errors with `cause` retained. -- Add regression tests for malformed/partial protocol frames. - -## PR 7: Remove Legacy Provider Stack - -- Remove `ProviderManager` + legacy checkpoint integration from runtime path. -- Remove `FilesystemCheckpointStore` from active server flow (keep only if explicitly needed for compatibility tooling). -- Update tests to assert only Effect service path is used. - -## PR 8: Final Cleanup + Docs - -- Update architecture docs with final layer graph and service boundaries. -- Document error model and recovery semantics. -- Trim dead compatibility code and stale plan references. diff --git a/.plans/13-provider-service-integration-tests.md b/.plans/13-provider-service-integration-tests.md deleted file mode 100644 index f3fe4edf02ac..000000000000 --- a/.plans/13-provider-service-integration-tests.md +++ /dev/null @@ -1,123 +0,0 @@ -# ProviderService Integration Test Plan - -Goal: - -- Validate end-to-end `ProviderService` behavior with real layers: - - `ProviderServiceLive` - - `CheckpointServiceLive` - - `CheckpointStoreLive` - - `CheckpointRepositoryLive` (sqlite in-memory) - - `ProviderSessionDirectoryLive` -- Only fake the adapter event source (deterministic Codex-like stream). -- Avoid mocking checkpointing/persistence orchestration logic. - -## Test Harness - -Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`: - -- Service contract: `ProviderAdapterShape`. -- Internal state: - - session registry (session + cwd + threadId) - - thread snapshot store (`threadId`, `turns`) - - event subscribers -- Behavior: - - `startSession`: creates session with threadId. - - `sendTurn`: appends a deterministic turn snapshot and emits ordered events: - - `turn/started` - - `item/started` / `item/completed` (tool + approval variants depending on scenario) - - `item/agentMessage/delta` chunks - - `turn/completed` - - optional "mutator" callback per turn to change workspace files before completion. - - `readThread`, `rollbackThread`, `stopSession`, `stopAll`. - -Use real git-backed temporary workspaces in integration tests: - -- initialize repo with baseline commit -- run provider turn in workspace -- assert checkpoint diffs against real git refs - -## Core Integration Specs - -1. `startSession` initializes checkpoint root exactly once - -- Arrange: - - start provider session in git repo. -- Assert: - - `provider_checkpoints` contains root row (turn 0). - - checkpoint ref exists in git. - - second `startSession` for new session creates a new independent root. - -2. Turn without filesystem change - -- Arrange: - - emit normal turn events, no file mutation. -- Assert: - - provider subscribers receive: - - `turn/started` - - `turn/completed` - - synthetic `checkpoint/captured` - - `listCheckpoints` returns root + turn 1. - - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff. - -3. Turn with filesystem change - -- Arrange: - - mutate `README.md` during turn. -- Assert: - - `listCheckpoints` returns root + turn 1. - - `getCheckpointDiff(0 -> 1)` contains file path and hunk. - - persisted checkpoint metadata includes non-empty `checkpointRef`. - -4. Multi-turn sequencing and checkpoint monotonicity - -- Arrange: - - turn 1: no file change - - turn 2: file change - - turn 3: file change -- Assert: - - turn counts are monotonic and contiguous in DB (0,1,2,3). - - latest checkpoint is marked current. - - diffs for adjacent turns map to expected filesystem deltas. - -5. Revert to checkpoint - -- Arrange: - - execute 3 turns with at least one file-changing turn. - - call `revertToCheckpoint(turnCount=1)`. -- Assert: - - workspace content matches turn 1 state. - - adapter `rollbackThread` called with `numTurns=2`. - - DB rows for turns >1 are removed. - - later refs are deleted from git. - -6. Capture failure surface - -- Arrange: - - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails. -- Assert: - - `ProviderService` emits `checkpoint/captureError`. - - no partial metadata/ref divergence is left behind. - -## WebSocket Coverage (Thin Integration) - -Add one ws server integration spec: - -- Subscribe to `providers.event`. -- Run a deterministic provider turn through ws methods. -- Assert push stream includes: - - `turn/started`, tool events, `turn/completed`, `checkpoint/captured`. -- Assert orchestration projection still updates assistant message and turn diff summary. - -## Proposed PR Split - -PR A: - -- Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup). - -PR B: - -- Core ProviderService integration specs (cases 1-4). - -PR C: - -- Revert + failure-path specs (cases 5-6) + ws thin integration spec. diff --git a/.plans/14-server-authoritative-event-sourcing-cleanup.md b/.plans/14-server-authoritative-event-sourcing-cleanup.md deleted file mode 100644 index e5c5023205a6..000000000000 --- a/.plans/14-server-authoritative-event-sourcing-cleanup.md +++ /dev/null @@ -1,227 +0,0 @@ -# Server-Authoritative Event-Sourcing Cleanup Plan - -Goal: - -- Move to a cleaner service architecture with: - - durable, server-authoritative event sourcing - - strict command routing/validation - - pluggable provider adapters - - explicit separation between transport, domain orchestration, provider runtime, and persistence - -## Target Service Graph (ASCII) - -```text - +---------------------------+ - | wsServer | - | transport | - +---------------------------+ - | orchestration.dispatchCommand - v - +-------------------------------------------+ - | OrchestrationCommandRouter | - +-------------------------------------------+ - | - v - +-------------------------------------------+ - | OrchestrationCommandHandlers | - +-------------------------------------------+ - | - v - +-------------------------------------------+ - | OrchestrationEventStore | - +-------------------------------------------+ - | - v - +-------------------------------------------+ - | OrchestrationProjectionService | - +-------------------------------------------+ - | snapshot/replay - +---------------------------> wsServer - - -wsServer -- providers.* RPC --> +---------------------------+ - | ProviderService | - +---------------------------+ - | | - v v - +-------------------+ +-------------------------+ - | ProviderSession | | ProviderAdapterRegistry | - | Registry (durable)| +-------------------------+ - +-------------------+ | - ^ v - | +-------------------------+ - | | ProviderAdapter(s) | - | +-------------------------+ - | | - | runtime events v - | +---------------------------+ - +----------| ProviderRuntimeIngestion | - +---------------------------+ - | | | - v v v - Router Session Checkpoint - Registry Service - - +-------------------------------------------+ - | CheckpointService | - +-------------------------------------------+ - | | | - v v v - +--------------------+ +-------------+ +-------------------+ - | CheckpointCatalog | | Checkpoint | | ProviderAdapter(s)| - | (durable) | | Store (git) | | (read/rollback) | - +--------------------+ +-------------+ +-------------------+ - | - v - +------+ - |SQLite| - +------+ - -OrchestrationEventStore ------> SQLite -OrchestrationProjectionService -> SQLite -ProviderSessionRegistry ------> SQLite -CheckpointCatalog ------> SQLite -``` - -## Commit Series - -### Commit 1: Split public vs system orchestration command contracts - -- Create separate schemas/types: - - `ClientOrchestrationCommandSchema` - - `SystemOrchestrationCommandSchema` - - `OrchestrationCommandSchema = union(client, system)` -- Ensure client transport can only submit client commands. -- Keep system commands for server-internal workflows only. -- Expected files: - - `packages/contracts/src/orchestration.ts` - - `apps/server/src/wsServer.ts` - - orchestration/service tests -- Tests: - - reject system-only command via WS dispatch path - - preserve internal dispatch functionality for system commands - -### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary - -- Add dedicated router service to validate, authorize, and route commands. -- Move command-to-event mapping out of `orchestration/Layer.ts` into handlers. -- Add aggregate-level invariant checks before append (thread exists, project exists, etc.). -- Expected files: - - `apps/server/src/orchestration/Services/CommandRouter.ts` (new) - - `apps/server/src/orchestration/Layers/CommandRouter.ts` (new) - - `apps/server/src/orchestration/Layer.ts` - - `apps/server/src/orchestration/reducer.ts` (only if needed for event payload changes) -- Tests: - - router validation and invariant failures - - handler happy-path tests per command type - -### Commit 3: Harden event store for idempotency + optimistic append metadata - -- Add DB-level idempotency guard for `command_id` (`UNIQUE` where non-null). -- Extend append API to support idempotent replays and deterministic return of prior event on duplicate `commandId`. -- Add optional aggregate version metadata for future optimistic concurrency. -- Expected files: - - `apps/server/src/persistence/Migrations/00x_*.ts` (new migration) - - `apps/server/src/persistence/Services/OrchestrationEvents.ts` - - `apps/server/src/persistence/Layers/OrchestrationEvents.ts` -- Tests: - - duplicate command ID append returns same event/sequence (or explicit idempotent behavior) - - concurrent append behavior stays ordered and deterministic - -### Commit 4: Extract provider-runtime -> orchestration bridge from `wsServer` - -- Create `ProviderRuntimeIngestionService` that: - - subscribes to `ProviderService.streamEvents` - - translates runtime events into orchestration commands - - dispatches through router/engine -- Remove provider-to-orchestration state mutation logic from `wsServer`. -- Expected files: - - `apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts` (new) - - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` (new) - - `apps/server/src/wsServer.ts` -- Tests: - - ingestion service mapping tests (turn started/completed, message delta/completed, runtime error) - - ws integration confirms same external push behavior - -### Commit 5: Make session directory durable (`ProviderSessionRegistry`) - -- Replace in-memory-only `ProviderSessionDirectoryLive` with persistence-backed registry. -- Keep in-memory cache optional, but source of truth must be persistent. -- Add startup reconciliation to prune dead sessions / keep known thread mapping. -- Expected files: - - `apps/server/src/provider/Services/ProviderSessionDirectory.ts` (or new SessionRegistry service) - - `apps/server/src/provider/Layers/ProviderSessionDirectory.ts` - - `apps/server/src/persistence/Migrations/00x_*.ts` (new table/indexes) - - provider persistence tests -- Tests: - - survives server restart with correct mapping - - stale session cleanup semantics - -### Commit 6: Re-key checkpoint metadata from session to thread identity - -- Change checkpoint catalog primary identity from `provider_session_id` to durable `thread_id`. -- Keep `session_id` as nullable metadata only. -- Update checkpoint flows (`initialize`, `capture`, `list`, `diff`, `revert`) to use thread identity. -- Expected files: - - `apps/server/src/persistence/Migrations/00x_*.ts` (checkpoint schema migration) - - `apps/server/src/persistence/Services/Checkpoints.ts` - - `apps/server/src/persistence/Layers/Checkpoints.ts` - - `apps/server/src/checkpointing/Layers/CheckpointService.ts` -- Tests: - - resume/new session over same thread sees same checkpoint history - - revert/diff still work after session churn - -### Commit 7: Add durable projection persistence for orchestration read models - -- Introduce projection tables/snapshots persisted in DB to avoid full replay dependency. -- Keep event stream as source of truth; projection rebuild stays deterministic. -- `getSnapshot` reads from projection store (memory cache optional). -- Expected files: - - `apps/server/src/persistence/Migrations/00x_*.ts` (projection tables) - - `apps/server/src/orchestration/*` projection service/layer - - `apps/server/src/wsServer.ts` (snapshot/replay path wiring) -- Tests: - - cold boot snapshot load without replaying full history in process - - projection rebuild from events yields same result as previous reducer semantics - -### Commit 8: Narrow `ProviderService` responsibilities - -- Keep `ProviderService` focused on provider RPC/session lifecycle + unified runtime stream. -- Move checkpoint-capture side effects out of provider event worker into dedicated ingestion/checkpoint pipeline service. -- Preserve adapter pluggability and provider-neutral contracts. -- Expected files: - - `apps/server/src/provider/Layers/ProviderService.ts` - - new orchestration/checkpoint runtime coordinator service(s) -- Tests: - - provider service routing stays intact - - checkpoint capture still triggered by turn completion through new coordinator - -### Commit 9: Look over schemas (contracts and events) - -- Scan for unused schemas. -- Use effect/Schema everywhere -- Analyze which we need - - RPC Input/Output (both for routeRequest and command handler) - - Event payloads - - Persistence entities - -### Commit 10: Remove dead legacy path and finalize docs - -- Remove unused legacy manager/store path from active architecture: - - `providerManager.ts` - - `filesystemCheckpointStore.ts` (if no longer needed by tests/tools) -- Look over effect services for unused methods, errors, etc -- Update architecture docs with final service boundaries and boot/runtime graph. -- Expected files: - - legacy files + references - - `AGENTS.md`/docs as needed - - `.plans` docs linkage -- Tests: - - full server integration suite passes on Effect-only path - - no regressions in WS protocol behavior - -## Risk Controls - -- Keep WS method names and payload contracts stable throughout. -- Gate each commit with targeted integration tests before moving forward. -- Avoid broad event-type churn in one step; migrate schemas incrementally with clear compatibility windows. diff --git a/.plans/15-effect-server.md b/.plans/15-effect-server.md deleted file mode 100644 index 5e245bb8e9ef..000000000000 --- a/.plans/15-effect-server.md +++ /dev/null @@ -1,11 +0,0 @@ -Rewrite `createServer` and `index.ts` to be Effect native. - -Maybe use `effect/unstable/Socket` for the web socket server - -- https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts -- https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts - -- Migrate remaining runtime code to Effect - - `gitManager` -> `src/git` - - `terminalManager` -> `src/terminal` (Manager + PTY) - - ... diff --git a/.plans/16-pr89-review-remediation-phases.md b/.plans/16-pr89-review-remediation-phases.md deleted file mode 100644 index 81ed6bd9f2b7..000000000000 --- a/.plans/16-pr89-review-remediation-phases.md +++ /dev/null @@ -1,165 +0,0 @@ -# PR #89 Review Remediation Plan (Phased) - -## How To Use These Files - -- Working checklist with updateable status per item (single source of truth): `.plans/16c-pr89-remediation-checklist.md` -- This file (`16-pr89-review-remediation-phases.md`): phase strategy and grouping. - -## Scope - -- Source: GitHub review comments on PR #89 (`Add server-side orchestration engine with event sourcing`). -- Triage baseline used here: - - Total threads: 185 - - Outdated: 94 (excluded) - - Active unresolved: 85 - - Invalid/false-positive: 3 (excluded) - - Duplicate reposts: collapsed - - Unique actionable findings after filtering: 58 - - Post-rewrite validity audit: 5 additional stale items marked invalid, leaving 53 actionable (`34 valid` + `19 partially-valid`) - -## Phase 0: Canonical Triage Lock - -- Create a single tracking checklist for the 53 currently actionable findings. -- Map every duplicate thread to its canonical item. -- Mark invalid/false-positive items with explicit rationale. - -Exit criteria: - -- Every open thread is mapped to one canonical fix item or marked invalid. - -## Phase 1: Runtime Survival and Critical Event Wiring - -Related bug groups solved together: - -- Worker loop/fiber fatal error handling in orchestration reactors. -- WebSocket message error boundaries and unhandled rejection guards. -- Close invalid `providers.event` review findings as documented architecture mismatch (no code change expected). - -Primary files: - -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` -- `apps/server/src/orchestration/Layers/CheckpointReactor.ts` -- `apps/server/src/wsServer.ts` - -Exit criteria: - -- A single event-processing failure cannot permanently stop ingestion/reactor loops. -- WS message handling cannot produce unhandled promise rejections. -- Invalid provider-event-channel review findings are closed with architecture rationale. - -## Phase 2: State Consistency and Ordering - -Related bug groups solved together: - -- Fire-and-forget revert completion causing consistency windows. -- Non-atomic append/projection paths and retry behavior. -- Race-sensitive thread/event association issues. - -Primary files: - -- `apps/server/src/orchestration/Layers/CheckpointReactor.ts` -- `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` -- `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` - -Exit criteria: - -- Revert flow is deterministically reflected in read model updates. -- Append/project failure mode is explicit and safe under retry. -- No cross-thread misassociation under concurrent runtime events. - -## Phase 3: Checkpointing Correctness Bundle - -Related bug groups solved together: - -- Checkpoint input normalization consistency. -- Snapshot/projector coverage mismatches. -- Checkpoint ref/workspace CWD utility duplication. -- Checkpoint diff/error handling behavior gaps. - -Primary files: - -- `apps/server/src/checkpointing/Layers/CheckpointStore.ts` -- `apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts` -- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` -- `apps/server/src/orchestration/Layers/CheckpointReactor.ts` -- `apps/server/src/wsServer.ts` - -Exit criteria: - -- Checkpoint capture/restore/revert paths use one normalization policy. -- Required projectors are actually represented in snapshot reads. -- Shared checkpoint/ref/CWD helpers are centralized. - -## Phase 4: Memory and Lifecycle Hygiene - -Related bug groups solved together: - -- Unbounded in-memory dedup sets/maps. -- Missing cleanup/lifecycle protections in long-lived effects/resources. - -Primary files: - -- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` -- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` -- `apps/server/src/config.ts` - -Exit criteria: - -- Long-running server memory does not grow unbounded from dedup bookkeeping. -- Resource cleanup paths are registered for interruption/shutdown. - -## Phase 5: Transport, Parsing, and Platform Edge Cases - -Related bug groups solved together: - -- UTF-8 chunk boundary decode correctness. -- Markdown/file-link parsing edge cases. -- Shell/OS-specific PATH parsing behavior. -- Git rename parsing and small keybinding edge cases. - -Primary files: - -- `apps/server/src/wsServer.ts` -- `apps/server/src/git/Layers/CodexTextGeneration.ts` -- `apps/web/src/markdown-links.ts` -- `apps/server/src/os-jank.ts` -- `apps/server/src/git/Layers/GitCore.ts` -- `apps/server/src/keybindings.ts` - -Exit criteria: - -- Edge-case parsers are robust across valid but non-trivial inputs. -- Platform-dependent command behavior has safe fallbacks. - -## Phase 6: Build and Maintainability Cleanup - -Related bug groups solved together: - -- Build script/runtime assumption cleanup. -- Redundant error-union declarations and utility/type duplication. -- Non-functional cleanup comments/docs markers. - -Primary files: - -- `apps/server/package.json` -- `apps/server/src/checkpointing/Errors.ts` -- Shared utility locations introduced during earlier phases -- `AGENTS.md` (if cleanup is still pending) - -Exit criteria: - -- Build path is explicit and environment-safe. -- Redundant types/utilities are removed in favor of single sources of truth. - -## Phase 7: Verification and Closeout - -- Add backend tests for all behavioral fixes (integration-focused; external services may be layered/mocked, core business logic not mocked out). -- Run lint and backend tests for all touched packages. -- Resolve threads with fix references per canonical checklist item. - -Exit criteria: - -- Lint passes. -- Backend tests pass. -- All actionable review threads are resolved or explicitly justified. diff --git a/.plans/16c-pr89-remediation-checklist.md b/.plans/16c-pr89-remediation-checklist.md deleted file mode 100644 index 6512e9246761..000000000000 --- a/.plans/16c-pr89-remediation-checklist.md +++ /dev/null @@ -1,478 +0,0 @@ -# PR #89 Remediation Checklist (Consolidated) - -_Last updated: 2026-02-26_ - -This is the working checklist for remediation execution. - -Status values: - -- `TODO`: Not started -- `IN_PROGRESS`: Currently being worked -- `BLOCKED`: Waiting on decision/dependency -- `DONE`: Implemented and verified -- `CLOSED_INVALID`: Stale/invalid review finding - -Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` - -## Active Checklist - -### Phase 1 - -- [x] `C002` A dispatch error in `processEvent` will terminate the `Effect.forever` loop, permanently halting event ingestion. Consider adding error recovery (e.g., `Effect.catchAll` with logging) around `processEvent` so failures don't kill the fiber. - - Status: `DONE` - - Verdict: `valid` - - Severity: `High` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:333` - - Threads: PRRT_kwDORLtfbc5wj4cH, PRRT_kwDORLtfbc5wnWwF, PRRT_kwDORLtfbc5wyTaP, PRRT_kwDORLtfbc5wzliw, PRRT_kwDORLtfbc5w0_g3, PRRT_kwDORLtfbc5w1HGT (+5 duplicate thread(s)) - - Audit note: Ingestion worker loop can terminate on unhandled processEvent failure. - -- [x] `C003` Consider attaching a no-op error listener before `socket.write` (e.g., `socket.on('error', () => {})`) to prevent an unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake. - - Status: `DONE` - - Verdict: `valid` - - Severity: `High` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:75` - - Threads: PRRT_kwDORLtfbc5v-cf4 - - Audit note: Upgrade reject writes then destroys socket without defensive error listener. - -- [x] `C012` Forked revert dispatch risks read model inconsistency - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:542` - - Threads: PRRT_kwDORLtfbc5whszW, PRRT_kwDORLtfbc5wyTaS, PRRT_kwDORLtfbc5wzli0, PRRT_kwDORLtfbc5w0_g4, PRRT_kwDORLtfbc5w1HGX (+4 duplicate thread(s)) - - Audit note: Revert completion dispatch remains forked; state consistency window remains. - -- [ ] `C019` ProviderRuntimeIngestion processes events for wrong thread on race - - Status: `TODO` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:178` - - Threads: PRRT_kwDORLtfbc5wkPaL - - Audit note: SessionId-only routing can misassociate events under races/rebinds. - -- [x] `C020` On `message.completed`, the message ID is added to the set and `thread.message.assistant.complete` is dispatched. On `turn.completed`, the same set is iterated and `thread.message.assistant.complete` is dispatched again for each ID—including already-completed ones. Consider removing message IDs from the set after dispatching on `message.completed`, or filtering out already-completed IDs before the `turn.completed` loop. - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:266` - - Threads: PRRT_kwDORLtfbc5w1GPr - - Audit note: Duplicate complete dispatch exists; downstream impact often idempotent. - -- [x] `C026` Consider adding `.catch(() => {})` after `Effect.runPromise(handleMessage(ws, raw))` to prevent unhandled rejections from crashing the server if `encodeResponse` or setup logic fails. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:545` - - Threads: PRRT_kwDORLtfbc5wj4cE - - Audit note: runPromise result still not caught; rejection can surface unhandled. - -- [x] `C027` WS message handler can cause unhandled promise rejection - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/wsServer.ts:545` - - Threads: PRRT_kwDORLtfbc5wyTaW, PRRT_kwDORLtfbc5wzli3 (+1 duplicate thread(s)) - - Audit note: Same unhandled rejection path remains in WS message handler. - -- [x] `C042` Duplicated `resolveThreadWorkspaceCwd` across three files - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:62` - - Threads: PRRT_kwDORLtfbc5wzli2 - - Audit note: Duplication exists but one instance is variant logic, so impact is moderate. - -- [x] `C043` Duplicated workspace CWD resolution logic across reactor modules - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:62` - - Threads: PRRT_kwDORLtfbc5wnWwM, PRRT_kwDORLtfbc5w1C3-, PRRT_kwDORLtfbc5w1HGZ (+2 duplicate thread(s)) - - Audit note: Workspace CWD resolution duplication still present across modules. - -- [x] `C044` Checkpoint reactor swallows diff errors silently for `turn.completed` - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:274` - - Threads: PRRT_kwDORLtfbc5wkPaO - - Audit note: Errors are swallowed to empty diff with warning; not fully silent but still lossy. - -- [x] `C045` `truncateDetail` slices to `limit - 1` then appends `"..."` (3 chars), producing strings of length `limit + 2`. Consider slicing to `limit - 3` instead.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:29` - - Threads: PRRT_kwDORLtfbc5wzp4R - - Audit note: truncateDetail still overshoots limit. - -- [x] `C046` `latestMessageIdByTurnKey` is written to but never read, and `clearAssistantMessageIdsForTurn` doesn't clear its entries—only `clearTurnStateForSession` does. Consider removing this map entirely if unused, or clearing it alongside `turnMessageIdsByTurnKey` in `clearAssistantMessageIdsForTurn`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Runtime resilience and failure handling` - - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:133` - - Threads: PRRT_kwDORLtfbc5wxvIQ - - Audit note: latestMessageIdByTurnKey still unused/unpruned in per-turn clear path. - -- [x] `C053` Consider using `socket.end(response)` instead of `socket.write(response)` + `socket.destroy()` to ensure the HTTP error response is fully flushed before closing the connection. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:83` - - Threads: PRRT_kwDORLtfbc5v-WPD - - Audit note: Still uses write+destroy rather than end() for rejection response. - -- [ ] `C054` When array chunks contain a multi-byte UTF-8 character split across boundaries, decoding each chunk separately produces replacement characters. Consider using `Buffer.concat()` on all chunks before calling `.toString("utf8")`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `WebSocket robustness` - - File: `apps/server/src/wsServer.ts:104` - - Threads: PRRT_kwDORLtfbc5whtrR - - Audit note: Array chunk UTF-8 decode remains vulnerable to split multibyte corruption. - -- [x] `C059` Suggestion: don’t spread `params` into `body`; it can override `_tag` and mishandle non-object values. Keep `_tag` separate and nest `params` under a single key (e.g., `data`), or validate `params` is a plain object. - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `WebSocket robustness` - - File: `apps/web/src/wsTransport.ts:59` - - Threads: PRRT_kwDORLtfbc5whtrN - - Audit note: Transport \_tag override risk exists but current callsites are constrained. - -### Phase 2 - -- [x] `C001` Non-atomic event appending can corrupt state on retry. If an error occurs mid-loop (lines 96-102) after some events are persisted but before the receipt is written, the command appears to fail. A retry generates new UUIDs via `crypto.randomUUID()` in the decider, appending duplicate events. Consider wrapping the loop in a transaction or using deterministic event IDs derived from `commandId`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `High` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:96` - - Threads: PRRT_kwDORLtfbc5wzp4T - - Audit note: Append/project/receipt are non-atomic; retry can duplicate events. - -- [x] `C013` If `projectionPipeline.projectEvent` fails after `eventStore.append` succeeds, the event is persisted but `readModel` isn't updated, causing desync. Consider updating the in-memory `readModel` immediately after append (before the external projection), so local state stays consistent regardless of downstream failures. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:99` - - Threads: PRRT_kwDORLtfbc5whtrM - - Audit note: Persisted event can outpace in-memory projection on mid-flight failure. - -- [x] `C015` The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/ProjectionPipeline.ts:99` - - Threads: PRRT_kwDORLtfbc5whxJO - - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated. - -- [x] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes _after_ the committed sequence. Consider persisting this pending state or processing these two events atomically.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Event ordering and state consistency` - - File: `apps/server/src/orchestration/Layers/ProjectionPipeline.ts:490` - - Threads: PRRT_kwDORLtfbc5wxvH8 - - Audit note: Pending turn-start map is in-memory only and not rebuilt on bootstrap. - -### Phase 3 - -- [x] `C008` Inconsistent input normalization across CheckpointStore methods - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:94` - - Threads: PRRT*kwDORLtfbc5widJw, PRRT_kwDORLtfbc5wnWv*, PRRT_kwDORLtfbc5w0_g7, PRRT_kwDORLtfbc5w1C36 (+3 duplicate thread(s)) - - Audit note: Edge schema strategy is in place across contracts/consumers (trim/normalize via schemas and decode at boundaries); CheckpointStore remains an internal repository boundary. - -- [x] `C017` `REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Checkpointing correctness` - - File: `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:71` - - Threads: PRRT_kwDORLtfbc5wiLhQ - - Audit note: Snapshot sequence can under-report due to extra projectors, but replay impact is lower now. - -- [x] `C033` Three error classes defined but never instantiated anywhere - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Errors.ts:51` - - Threads: PRRT_kwDORLtfbc5wlYgo - - Audit note: Original claim overstated; some errors used, others appear unused. - -- [x] `C034` Redundant `CheckpointInvariantError` in `CheckpointServiceError` union type - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Errors.ts:79` - - Threads: PRRT_kwDORLtfbc5wj5fn - - Audit note: CheckpointInvariantError remains redundantly included in service union. - -- [x] `C035` Redundant error type in CheckpointServiceError union definition - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Checkpointing correctness` - - File: `apps/server/src/checkpointing/Errors.ts:79` - - Threads: PRRT_kwDORLtfbc5wlYgs, PRRT_kwDORLtfbc5wxsO6, PRRT_kwDORLtfbc5w1C4B (+2 duplicate thread(s)) - - Audit note: Same as C034. - -### Phase 4 - -- [ ] `C018` Unbounded memory growth in turn start deduplication set - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Memory/resource growth` - - File: `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:84` - - Threads: PRRT_kwDORLtfbc5whszQ, PRRT_kwDORLtfbc5wl2A8, PRRT_kwDORLtfbc5wyTaT, PRRT_kwDORLtfbc5wzliz, PRRT_kwDORLtfbc5w0_g-, PRRT_kwDORLtfbc5w1HGW (+5 duplicate thread(s)) - - Audit note: handledTurnStartKeys still grows without pruning. - -### Phase 5 - -- [ ] `C009` Git's braced rename syntax (e.g., `src/{old => new}/file.ts`) isn't handled correctly. The current slice after `=>` produces invalid paths like `new}/file.ts`. Consider expanding the braces to construct the full destination path.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/git/Layers/GitCore.ts:41` - - Threads: PRRT_kwDORLtfbc5w1CxT - - Audit note: Braced rename parsing still breaks paths like src/{old => new}/file.ts. - -- [ ] `C010` `loadCustomKeybindingsConfig` fails when the config file doesn't exist, which is expected for new users. Consider catching `ENOENT` and returning an empty array instead.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/keybindings.ts:418` - - Threads: PRRT_kwDORLtfbc5wxvIJ - - Audit note: ENOENT for missing keybindings config still not handled as empty/default. - -- [ ] `C022` Fish shell outputs `$PATH` as space-separated, not colon-separated. Consider checking if the shell is fish and using `string join : $PATH` instead, or validating the result contains colons before assigning. - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/os-jank.ts:10` - - Threads: PRRT_kwDORLtfbc5wkRZM - - Audit note: fish PATH formatting risk still exists in os-jank path recovery. - -- [ ] `C023` Using `-il` flags causes the shell to source profile scripts that may print banners or other text, polluting the captured `PATH`. Consider using `-lc` (login only, non-interactive) to reduce unwanted output. - - Status: `TODO` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/os-jank.ts:10` - - Threads: PRRT_kwDORLtfbc5wj4cM - - Audit note: -ilc shell invocation can pollute captured PATH output. - -- [x] `C029` `parseFileUrlHref` already decodes the path (line 46), but `safeDecode` is called again here, corrupting filenames containing `%` sequences. Consider skipping the decode when `fileUrlTarget` is non-null. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/web/src/markdown-links.ts:105` - - Threads: PRRT_kwDORLtfbc5wnVsU - - Audit note: file URL decoding still double-decodes in one path. - -- [x] `C030` `EXTERNAL_SCHEME_PATTERN` matches `script.ts:10` as a scheme because `.ts:` looks like `scheme:`. Consider requiring `://` after the colon, or checking that what follows the colon is not just digits.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Medium` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/web/src/markdown-links.ts:111` - - Threads: PRRT_kwDORLtfbc5wnVsK - - Audit note: Scheme regex still misclassifies script.ts:10 as external scheme. - -- [ ] `C038` Multi-byte UTF-8 characters split across chunks will be corrupted when decoding each chunk separately. Consider accumulating all chunks first, then decoding once, or use `TextDecoder` with `stream: true`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/git/Layers/CodexTextGeneration.ts:136` - - Threads: PRRT_kwDORLtfbc5w1GPo - - Audit note: Chunk-by-chunk UTF-8 decode can still corrupt split multibyte characters. - -- [x] `C039` The `+` key can be parsed (via trailing `+` handling) but cannot be encoded because `shortcut.key.includes("+")` returns true for the literal `+` key. Consider checking `shortcut.key === "+"` separately and encoding it as `"space"` style (e.g., a special token), or adjusting the condition to allow the single `+` character.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/keybindings.ts:352` - - Threads: PRRT_kwDORLtfbc5wxvIB - - Audit note: Parser/encoder mismatch remains, but encoder path currently low-use. - -- [x] `C040` `upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Edge-case parsing/platform behavior` - - File: `apps/server/src/keybindings.ts:488` - - Threads: PRRT_kwDORLtfbc5wxvIA - - Audit note: upsertKeybindingRule read-modify-write remains race-prone. - -### Phase 6 - -- [ ] `C028` Branch sync dispatches both server and stale local update - - Status: `TODO` - - Verdict: `partially-valid` - - Severity: `Medium` - - Area: `Other` - - File: `apps/web/src/components/BranchToolbar.tsx:102` - - Threads: PRRT_kwDORLtfbc5v-XCu - - Audit note: Optimistic local+server dual update is intentional but can temporarily diverge. - -- [x] `C037` `Effect.callback` should return a cleanup function to close the server(s) on fiber interruption. Without it, the `Net.Server` handles keep the process alive and leak the port if the effect is cancelled.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/config.ts:41` - - Threads: PRRT_kwDORLtfbc5wj4cO - - Audit note: Callback cleanup missing, but practical exposure is low in one-shot startup path. - -- [ ] `C047` `SqlSchema.findOneOption` can produce both SQL errors and decode errors, but `mapError` wraps all as `PersistenceSqlError`. Consider distinguishing `ParseError` from SQL errors and mapping decode failures to `PersistenceDecodeError` instead.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts:75` - - Threads: PRRT_kwDORLtfbc5wiaR- - - Audit note: Decode and SQL errors still collapsed into one persistence error kind. - -- [x] `C049` `JSON.stringify(cause)` returns `undefined` for `undefined`, functions, or symbols, violating the `string` return type. Consider coercing the result to a string (e.g., `String(JSON.stringify(cause))`) or adding a fallback. - - Status: `DONE` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/provider/Layers/ProviderService.ts:59` - - Threads: PRRT_kwDORLtfbc5wnVsI - - Audit note: JSON.stringify(cause) may return undefined despite string expectations. - -- [ ] `C050` The read-modify-write pattern (`getBySessionId` → merge → `upsert`) is susceptible to lost updates under concurrent writes. Consider wrapping in a transaction or adding optimistic concurrency control (e.g., version field) if concurrent session updates are expected.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/provider/Layers/ProviderSessionDirectory.ts:94` - - Threads: PRRT_kwDORLtfbc5wiLhY - - Audit note: ProviderSessionDirectory upsert remains read-merge-write without concurrency control. - -- [x] `C051` Using `??` for `providerThreadId` and `adapterKey` makes it impossible to clear these fields by passing `null`, since `null ?? existing` evaluates to `existing`. Consider using explicit `undefined` checks (like `resumeCursor` does) if clearing should be supported.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `DONE` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/provider/Layers/ProviderSessionDirectory.ts:119` - - Threads: PRRT_kwDORLtfbc5wxvH9 - - Audit note: Null-clearing issue is real for providerThreadId; adapterKey part overstated. - -- [ ] `C052` Race condition: `processHandle` may be `null` when `data` callback fires, since it's assigned after `Bun.spawn` returns. Consider initializing `BunPtyProcess` first, then passing it to the callback to avoid losing initial output.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/server/src/terminal/Layers/BunPTY.ts:97` - - Threads: PRRT_kwDORLtfbc5w1CxE - - Audit note: Data callback may race before processHandle assignment. - -- [ ] `C056` When `onOpenChange` is provided without `open`, the internal `_open` state never updates because `setOpenProp` takes precedence. Consider calling `_setOpen` when `openProp === undefined`, regardless of whether `setOpenProp` exists. - - Status: `TODO` - - Verdict: `partially-valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/web/src/components/ui/sidebar.tsx:114` - - Threads: PRRT_kwDORLtfbc5wxvIq - - Audit note: Bug pattern exists, but current callsites mostly avoid triggering it. - -- [ ] `C057` The `resizable` object is recreated on every render, causing `SidebarRail`'s `useEffect` to repeatedly read localStorage and update the DOM. Consider memoizing the object with `useMemo`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/web/src/routes/_chat.$threadId.tsx:105` - - Threads: PRRT_kwDORLtfbc5wyWz4 - - Audit note: Resizable object recreation still retriggers effect/storage reads. - -- [ ] `C058` When `localStorage.getItem()` returns `null`, `Number(null)` evaluates to `0`, which passes `Number.isFinite(0)`. This causes the sidebar to clamp to `minWidth` on first load, overriding the `DIFF_INLINE_DEFAULT_WIDTH` CSS clamp. Consider checking for `null` or empty string before parsing, e.g. guard with `storedWidth === null || storedWidth === ''`.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `apps/web/src/routes/_chat.$threadId.tsx:122` - - Threads: PRRT_kwDORLtfbc5wnVsX - - Audit note: Number(null) -> 0 path still forces min width on initial load. - -- [ ] `C060` `defaultModel` should be `Schema.optional(Schema.NullOr(Schema.String))` to allow clearing the value. Currently there's no way to reset it to `null` since omitting means "no change" in patch semantics.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: - - Status: `TODO` - - Verdict: `valid` - - Severity: `Low` - - Area: `Other` - - File: `packages/contracts/src/orchestration.ts:253` - - Threads: PRRT_kwDORLtfbc5whxJC - - Audit note: Schema still cannot express null clear for defaultModel patch. - -## Closed Invalid Items - -- [x] `C014` Engine error handler catches all errors including non-invariant ones - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:144` - - Threads: PRRT_kwDORLtfbc5wkPaJ - - Rationale: Broad catch is intentional for worker liveness; transactional dispatch path prevents the claimed non-invariant idempotency break in current design. - -- [x] `C021` Shared mutable default metadata object causes stale eventId - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/server/src/orchestration/decider.ts:27` - - Threads: PRRT_kwDORLtfbc5wkPaA - - Rationale: Stale-eventId claim no longer applies; eventId is regenerated per event. - -- [x] `C025` Duplicated checkpoint ref computation across two files - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/server/src/wsServer.ts:128` - - Threads: PRRT_kwDORLtfbc5wvwag - - Rationale: No longer duplicated; checkpoint ref helper now centralized. - -- [x] `C031` Revert uses wrong turn count from positional inference - - Status: `CLOSED_INVALID` - - Severity: `Medium` - - File: `apps/web/src/session-logic.ts:127` - - Threads: PRRT_kwDORLtfbc5v9SCp - - Rationale: Revert now uses explicit checkpointTurnCount first; positional fallback is non-primary. - -- [x] `C036` Duplicate `checkpointRefForThreadTurn` function in two production files - - Status: `CLOSED_INVALID` - - Severity: `Low` - - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:284` - - Threads: PRRT_kwDORLtfbc5wiqFX - - Rationale: No longer duplicated; single production source via Refs.ts. - -- [x] `C055` Duplicate `checkpointRefForThreadTurn` function across files - - Status: `CLOSED_INVALID` - - Severity: `Low` - - File: `apps/server/src/wsServer.ts:128` - - Threads: PRRT_kwDORLtfbc5wkPaG - - Rationale: No longer duplicated; helper is centralized. diff --git a/.plans/17-claude-agent.md b/.plans/17-claude-agent.md deleted file mode 100644 index a2d906e0e047..000000000000 --- a/.plans/17-claude-agent.md +++ /dev/null @@ -1,441 +0,0 @@ -# Plan: Claude Code Integration (Orchestration Architecture) - -## Why this plan was rewritten - -The previous plan targeted a pre-orchestration architecture (`ProviderManager`, provider-native WS event methods, and direct provider UI wiring). The current app now routes everything through: - -1. `orchestration.dispatchCommand` (client intent) -2. `OrchestrationEngine` (decide + persist + publish domain events) -3. `ProviderCommandReactor` (domain intent -> `ProviderService`) -4. `ProviderService` (adapter routing + canonical runtime stream) -5. `ProviderRuntimeIngestion` (provider runtime -> internal orchestration commands) -6. `orchestration.domainEvent` (single push channel consumed by web) - -Claude integration must plug into this path instead of reintroducing legacy provider-specific flows. - ---- - -## Current constraints to design around (post-Stage 1) - -1. Provider runtime ingestion expects canonical `ProviderRuntimeEvent` shapes, not provider-native payloads. -2. Start input now uses typed `providerOptions` and generic `resumeCursor`; top-level provider-specific fields were removed. -3. `resumeCursor` is intentionally opaque outside adapters and must never be synthesized from `providerThreadId`. -4. `ProviderService` still requires adapter `startSession()` to return a `ProviderSession` with `threadId`. -5. Checkpoint revert currently calls `providerService.rollbackConversation()`, so Claude adapter needs a rollback strategy compatible with current reactor behavior. -6. Web currently marks Claude as unavailable (`"Claude Code (soon)"`) and model picker is Codex-only. - ---- - -## Architecture target - -Add Claude as a first-class provider adapter that emits canonical runtime events and works with existing orchestration reactors without adding new WS channels or bypass paths. - -Key decisions: - -1. Keep orchestration provider-agnostic; adapt Claude inside adapter/layer boundaries. -2. Use the existing canonical runtime stream (`ProviderRuntimeEvent`) as the only ingestion contract. -3. Keep provider session routing in `ProviderService` and `ProviderSessionDirectory`. -4. Add explicit provider selection to turn-start intent so first turn can start Claude session intentionally. - ---- - -## Phase 1: Contracts and command shape updates - -### 1.1 Provider-aware model contract - -Update `packages/contracts/src/model.ts` so model resolution can be provider-aware instead of Codex-only. - -Expected outcomes: - -1. Introduce provider-scoped model lists (Codex + Claude). -2. Add helpers that resolve model by provider. -3. Preserve backwards compatibility for existing Codex defaults. - -### 1.2 Turn-start provider intent - -Update `packages/contracts/src/orchestration.ts`: - -1. Add optional `provider: ProviderKind` to `ThreadTurnStartCommand`. -2. Carry provider through `ThreadTurnStartRequestedPayload`. -3. Keep existing command valid when provider is omitted. - -This removes the implicit “Codex unless session already exists” behavior as the only path. - -### 1.3 Provider session start input for Claude runtime knobs (completed) - -Update `packages/contracts/src/provider.ts`: - -1. Move provider-specific start fields into typed `providerOptions`: - - `providerOptions.codex` - - `providerOptions.claudeCode` -2. Keep `resumeCursor` as the single cross-provider resume input in `ProviderSessionStartInput`. -3. Deprecate/remove `resumeThreadId` from the generic start contract. -4. Treat `resumeCursor` as adapter-owned opaque state. - -### 1.4 Contract tests (completed) - -Update/add tests in `packages/contracts/src/*.test.ts` for: - -1. New command payload shape. -2. Provider-aware model resolution behavior. -3. Breaking-change expectations for removed top-level provider fields. - ---- - -## Phase 2: Claude adapter implementation - -### 2.1 Add adapter service + layer - -Create: - -1. `apps/server/src/provider/Services/ClaudeAdapter.ts` -2. `apps/server/src/provider/Layers/ClaudeAdapter.ts` - -Adapter must implement `ProviderAdapterShape`. - -### 2.1.a SDK dependency and baseline config - -Add server dependency: - -1. `@anthropic-ai/claude-agent-sdk` - -Baseline adapter options to support from day one: - -1. `cwd` -2. `model` -3. `pathToClaudeCodeExecutable` (from `providerOptions.claudeCode.binaryPath`) -4. `permissionMode` (from `providerOptions.claudeCode.permissionMode`) -5. `maxThinkingTokens` (from `providerOptions.claudeCode.maxThinkingTokens`) -6. `resume` -7. `resumeSessionAt` -8. `includePartialMessages` -9. `canUseTool` -10. `hooks` -11. `env` and `additionalDirectories` (if needed for sandbox/workspace parity) - -### 2.2 Claude runtime bridge - -Implement a Claude runtime bridge (either directly in adapter layer or via dedicated manager file) that wraps Agent SDK query lifecycle. - -Required capabilities: - -1. Long-lived session context per adapter session. -2. Multi-turn input queue. -3. Interrupt support. -4. Approval request/response bridge. -5. Resume support via opaque `resumeCursor` (parsed inside Claude adapter only). - -#### 2.2.a Agent SDK details to preserve - -The adapter should explicitly rely on these SDK capabilities: - -1. `query()` returns an async iterable message stream and control methods (`interrupt`, `setModel`, `setPermissionMode`, `setMaxThinkingTokens`, account/status helpers). -2. Multi-turn input is supported via async-iterable prompt input. -3. Tool approval decisions are provided via `canUseTool`. -4. Resume support uses `resume` and optional `resumeSessionAt`, both derived by parsing adapter-owned `resumeCursor`. -5. Hooks can be used for lifecycle signals (`Stop`, `PostToolUse`, etc.) when we need adapter-originated checkpoint/runtime events. - -#### 2.2.b Effect-native session lifecycle skeleton - -```ts -import { query } from "@anthropic-ai/claude-agent-sdk"; -import { Effect } from "effect"; - -const acquireSession = (input: ProviderSessionStartInput) => - Effect.acquireRelease( - Effect.tryPromise({ - try: async () => { - const claudeOptions = input.providerOptions?.claudeCode; - const resumeState = readClaudeResumeState(input.resumeCursor); - const abortController = new AbortController(); - const result = query({ - prompt: makePromptAsyncIterable(), - options: { - cwd: input.cwd, - model: input.model, - permissionMode: claudeOptions?.permissionMode, - maxThinkingTokens: claudeOptions?.maxThinkingTokens, - pathToClaudeCodeExecutable: claudeOptions?.binaryPath, - resume: resumeState?.threadId, - resumeSessionAt: resumeState?.sessionAt, - signal: abortController.signal, - includePartialMessages: true, - canUseTool: makeCanUseTool(), - hooks: makeClaudeHooks(), - }, - }); - return { abortController, result }; - }, - catch: (cause) => - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId: "pending", - detail: "Failed to start Claude runtime session.", - cause, - }), - }), - ({ abortController }) => Effect.sync(() => abortController.abort()), - ); -``` - -#### 2.2.c AsyncIterable -> Effect Stream integration - -Preferred when available in the pinned Effect version: - -```ts -const sdkMessageStream = Stream.fromAsyncIterable( - session.result, - (cause) => - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId, - detail: "Claude runtime stream failed.", - cause, - }), -); -``` - -Portable fallback (already aligned with current server patterns): - -```ts -const sdkMessageStream = Stream.async((emit) => { - let cancelled = false; - void (async () => { - try { - for await (const message of session.result) { - if (cancelled) break; - emit.single(message); - } - emit.end(); - } catch (cause) { - emit.fail( - new ProviderAdapterProcessError({ - provider: "claudeCode", - sessionId, - detail: "Claude runtime stream failed.", - cause, - }), - ); - } - })(); - return Effect.sync(() => { - cancelled = true; - }); -}); -``` - -### 2.3 Canonical event mapping - -Claude adapter must translate Agent SDK output into canonical `ProviderRuntimeEvent`. - -Initial mapping target: - -1. assistant text deltas -> `content.delta` -2. final assistant text -> `item.completed` and/or `turn.completed` -3. approval requests -> `request.opened` -4. approval results -> `request.resolved` -5. system lifecycle -> `session.*`, `thread.*`, `turn.*` -6. errors -> `runtime.error` -7. plan/proposed-plan content when derivable - -Implementation note: - -1. Keep raw Claude message on `raw` for debugging. -2. Prefer canonical item/request kinds over provider-native enums. -3. If Claude emits extra event kinds we do not model yet, map them to `tool.summary`, `runtime.warning`, or `unknown`-compatible payloads instead of dropping silently. - -### 2.4 Resume cursor strategy - -Define Claude-owned opaque resume state, e.g.: - -```ts -interface ClaudeResumeCursor { - readonly version: 1; - readonly threadId?: string; - readonly sessionAt?: string; -} -``` - -Rules: - -1. Serialize only adapter-owned state into `resumeCursor`. -2. Parse/validate only inside Claude adapter. -3. Store updated cursor when Claude runtime yields enough data to resume safely. -4. Never overload orchestration thread id as Claude thread id. - -### 2.5 Interrupt and stop semantics - -Map orchestration stop/interrupt expectations onto SDK controls: - -1. `interruptTurn()` -> active query interrupt. -2. `stopSession()` -> close session resources and prevent future sends. -3. `rollbackThread()` -> see Phase 4. - ---- - -## Phase 3: Provider service and composition - -### 3.1 Register Claude adapter - -Update provider registry layer to include Claude: - -1. add `claudeCode` -> `ClaudeAdapter` -2. ensure `ProviderService.listProviderStatuses()` reports Claude availability - -### 3.2 Persist provider binding - -Current `ProviderSessionDirectory` already stores provider/thread binding and opaque `resumeCursor`. - -Required validation: - -1. Claude bindings survive restart. -2. resume cursor remains opaque and round-trips untouched. -3. stopAll + restart can recover Claude sessions when possible. - -### 3.3 Provider start routing - -Update `ProviderCommandReactor` / orchestration flow: - -1. If a thread turn start requests `provider: "claudeCode"`, start Claude if no active session exists. -2. If a thread already has Claude session binding, reuse it. -3. If provider switches between Codex and Claude, explicitly stop/rebind before next send. - ---- - -## Phase 4: Checkpoint and revert strategy - -Claude does not necessarily expose the same conversation rewind primitive as Codex app-server. Current architecture expects `providerService.rollbackConversation()`. - -Pick one explicit strategy: - -### Option A: provider-native rewind - -If SDK/runtime supports safe rewind: - -1. implement in Claude adapter -2. keep `CheckpointReactor` unchanged - -### Option B: session restart + state truncation shim - -If no native rewind exists: - -1. Claude adapter returns successful rollback by: - - stopping current Claude session - - clearing/rewriting stored Claude resume cursor to last safe resumable point - - forcing next turn to recreate session from persisted orchestration state -2. Document that rollback is “conversation reset to checkpoint boundary”, not provider-native turn deletion. - -Whichever option is chosen: - -1. behavior must be deterministic -2. checkpoint revert tests must pass under orchestration expectations -3. user-visible activity log should explain failures clearly when provider rollback is impossible - ---- - -## Phase 5: Web integration - -### 5.1 Provider picker and model picker - -Update web state/UI: - -1. allow choosing Claude as thread provider before first turn -2. show Claude model list from provider-aware model helpers -3. preserve existing Codex default behavior when provider omitted - -Likely touch points: - -1. `apps/web/src/store.ts` -2. `apps/web/src/components/ChatView.tsx` -3. `apps/web/src/types.ts` -4. `packages/shared/src/model.ts` - -### 5.2 Settings for Claude executable/options - -Add app settings if needed for: - -1. Claude binary path -2. default permission mode -3. default max thinking tokens - -Do not hardcode provider-specific config into generic session state if it belongs in app settings or typed `providerOptions`. - -### 5.3 Session rendering - -No new WS channel should be needed. Claude should appear through existing: - -1. thread messages -2. activities/worklog -3. approvals -4. session state -5. checkpoints/diffs - ---- - -## Phase 6: Testing strategy - -### 6.1 Contract tests - -Cover: - -1. provider-aware model schemas -2. provider field on turn-start command -3. provider-specific start options schema - -### 6.2 Adapter layer tests - -Add `ClaudeAdapter.test.ts` covering: - -1. session start -2. event mapping -3. approval bridge -4. resume cursor parse/serialize -5. interrupt behavior -6. rollback behavior or explicit unsupported error path - -Use SDK-facing layer tests/mocks only at the boundary. Do not mock orchestration business logic in higher-level tests. - -### 6.3 Provider service integration tests - -Extend provider integration coverage so Claude is exercised through `ProviderService`: - -1. start Claude session -2. send turn -3. receive canonical runtime events -4. restart/recover using persisted binding - -### 6.4 Orchestration integration tests - -Add/extend integration tests around: - -1. first-turn provider selection -2. Claude approval requests routed through orchestration -3. Claude runtime ingestion -> messages/activities/session updates -4. checkpoint revert behavior under Claude -5. stopAll/restart recovery - -These should validate real orchestration flows, not just adapter behavior. - ---- - -## Phase 7: Rollout order - -Recommended implementation order: - -1. contracts/provider-aware models -2. provider field on turn-start -3. Claude adapter skeleton + start/send/stream -4. canonical event mapping -5. provider registry/service wiring -6. orchestration recovery + checkpoint strategy -7. web provider/model picker -8. full integration tests - ---- - -## Non-goals - -1. Reintroducing provider-specific WS methods/channels. -2. Storing provider-native thread ids as orchestration ids. -3. Bypassing orchestration engine for Claude-specific UI flows. -4. Encoding Claude resume semantics outside adapter-owned `resumeCursor`. diff --git a/.plans/17-provider-neutral-runtime-determinism.md b/.plans/17-provider-neutral-runtime-determinism.md deleted file mode 100644 index d70ec1054863..000000000000 --- a/.plans/17-provider-neutral-runtime-determinism.md +++ /dev/null @@ -1,109 +0,0 @@ -# Plan: Provider-Neutral Runtime Determinism and Flake Elimination - -## Summary -Replace timing-sensitive websocket and orchestration behavior with explicit typed runtime boundaries, ordered push delivery, and server-owned completion receipts. The cutover is broad and single-shot: no compatibility shim, no mixed old/new transport. The design must reduce flakes without baking Codex-specific lifecycle semantics into generic runtime code. - -## Implementation Status - -All 7 sections are implemented. CI passes (format, lint, typecheck, test, browser test, build). One deferred item remains: the shared `WsTestClient` helper from section 7 — tests use direct transport subscription and receipt-based waits instead. - -### New files - -| File | Purpose | -|------|---------| -| `packages/shared/src/DrainableWorker.ts` | Queue-based Effect worker with deterministic `drain` signal | -| `packages/shared/src/schemaJson.ts` | Two-phase JSON→Schema decode helpers (`decodeJsonResult`, `formatSchemaError`) | -| `apps/server/src/wsServer/pushBus.ts` | `ServerPushBus` — ordered typed push pipeline with auto-incrementing sequence | -| `apps/server/src/wsServer/readiness.ts` | `ServerReadiness` — Deferred-based barriers for startup sequencing | -| `apps/server/src/orchestration/Services/RuntimeReceiptBus.ts` | Receipt schema union: checkpoint captured, diff finalized, turn quiesced | -| `apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts` | PubSub-backed receipt bus implementation | -| `apps/server/src/watchFileWithStatPolling.ts` | Stat-polling file watcher for containers where `fs.watch` is unreliable | -| `apps/server/vitest.config.ts` | Server-specific test config (timeout bumps) | -| `apps/server/src/wsServer/pushBus.test.ts` | Push bus serialization and welcome-gating tests | -| `packages/shared/src/DrainableWorker.test.ts` | Drainable worker enqueue/drain lifecycle tests | - -### Key modifications - -| File | Change | -|------|--------| -| `packages/contracts/src/ws.ts` | Channel-indexed `WsPushPayloadByChannel` map, `WsPush` union schema, `WsPushSequence` | -| `apps/server/src/wsServer.ts` | Integrated `ServerPushBus` and `ServerReadiness`; welcome gated on readiness | -| `apps/server/src/keybindings.ts` | Explicit runtime with `start`/`ready`/`snapshot`; dual `fs.watch` + stat-polling watcher | -| `apps/web/src/wsTransport.ts` | Connection state machine (`connecting`→`open`→`reconnecting`→`closed`→`disposed`); two-phase decode at boundary; cached latest push by channel | -| `apps/web/src/wsNativeApi.ts` | Removed decode logic; delegates to pre-validated transport messages | -| `apps/server/src/orchestration/Layers/CheckpointReactor.ts` | Uses `DrainableWorker`; publishes completion receipts | -| `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` | Uses `DrainableWorker` for command processing | -| `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` | Uses `DrainableWorker` for event ingestion | -| `apps/server/integration/OrchestrationEngineHarness.integration.ts` | Receipt-based waits replace polling loops | - -## Key Changes -### 1. Strengthen the generic boundaries, not the Codex boundary — DONE -- `ProviderRuntimeEvent` remains the canonical provider event contract; `ProviderService` remains the only cross-provider facade. -- Raw Codex payloads and event ordering stay isolated in `CodexAdapter.ts` and `codexAppServerManager.ts`. -- `ProviderKind` was not expanded. The runtime stays provider-neutral by contract. - -### 2. Replace loose websocket envelopes with channel-indexed typed pushes — DONE -- `packages/contracts/src/ws.ts` now derives push messages from a `WsPushPayloadByChannel` channel-to-schema map. `WsPush` is a union schema replacing `channel: string` + `data: unknown`. -- Every server push carries `sequence: number`, auto-incremented in `ServerPushBus`. -- `packages/shared/src/schemaJson.ts` provides structured decode diagnostics via `formatSchemaError`. -- `packages/contracts/src/ws.test.ts` covers typed push envelope validation and channel/payload mismatch rejection. - -### 3. Introduce explicit server readiness and a single push pipeline — DONE -- `apps/server/src/wsServer/pushBus.ts`: `ServerPushBus` with `publishAll` (broadcast) and `publishClient` (targeted) methods, backed by one ordered path. All pushes flow through it. -- `apps/server/src/wsServer/readiness.ts`: `ServerReadiness` with Deferred-based barriers for HTTP listening, push bus, keybindings, terminal subscriptions, and orchestration subscriptions. -- `server.welcome` is emitted only after connection-scoped and server-scoped readiness is complete. -- `wsServer.ts` no longer publishes directly from ad hoc background streams. - -### 4. Turn background watchers into explicit runtimes — DONE -- `apps/server/src/keybindings.ts` refactored as explicit `KeybindingsShape` service with `start`, `ready`, `snapshot` semantics. -- Initial config load, cache warmup, and dual watcher attachment (`fs.watch` + `watchFileWithStatPolling`) complete before `ready` resolves. -- `watchFileWithStatPolling.ts` is the thin adapter for environments where `fs.watch` is unreliable. - -### 5. Replace polling-based orchestration waiting with receipts — DONE -- `RuntimeReceiptBus` service defines three receipt types: `CheckpointBaselineCapturedReceipt`, `CheckpointDiffFinalizedReceipt` (with `status: "ready"|"missing"|"error"`), and `TurnProcessingQuiescedReceipt`. -- `CheckpointReactor`, `ProviderCommandReactor`, and `ProviderRuntimeIngestion` use `DrainableWorker` and publish receipts on completion. -- Integration harness and checkpoint tests await receipts instead of polling snapshots and git refs. - -### 6. Centralize client transport state and decoding — DONE -- `apps/web/src/wsTransport.ts` implements an explicit connection state machine: `connecting`, `open`, `reconnecting`, `closed`, `disposed`. -- Two-phase decode (JSON parse → Schema validate) happens at the transport boundary. `wsNativeApi.ts` receives pre-validated messages. -- Cached latest welcome/config modeled as explicit `latestPushByChannel` state. - -### 7. Replace ad hoc test helpers with semantic test clients — MOSTLY DONE -- `DrainableWorker` replaces timing-sensitive `Effect.sleep` with deterministic `drain()` across reactor tests. -- Orchestration harness waits on receipts/barriers instead of `waitForThread`, `waitForGitRef`, and retry loops. -- Behavioral assertions moved to deterministic unit-style harnesses; narrow integration tests kept for real filesystem/socket behavior. -- **Deferred:** Shared `WsTestClient` helper (connect, awaitSemanticWelcome, awaitTypedPush, trackSequence, matchRpcResponseById). Tests use direct transport subscription instead. - -## Provider-Coupling Guardrails -- No generic runtime API may depend on Codex-native event names, thread IDs, or request payload shapes. -- No readiness barrier may be defined as "Codex has emitted X." Readiness is owned by the server runtime, not by provider event order. -- No websocket channel payload may contain raw provider-native payloads unless the channel is explicitly debug/internal. -- Any provider-specific divergence must be exposed through provider capabilities from `ProviderService.getCapabilities()`, not `if provider === "codex"` branches in shared runtime code. -- Generic tests must use canonical `ProviderRuntimeEvent` fixtures. Codex-specific ordering and translation tests stay in adapter/app-server suites only. -- Keep UI/provider-specific knobs such as Codex-only options scoped to provider UX code. Do not pull them into generic transport or orchestration state. - -## Test Plan -- Contracts: - - schema tests for typed push envelopes and structured decode diagnostics - - ordering tests for `sequence` -- Server: - - readiness tests proving `server.welcome` cannot precede runtime readiness - - push bus tests proving terminal/config/orchestration pushes are serialized and typed - - keybindings runtime tests with fake watch source plus one real watcher integration test -- Orchestration: - - receipt tests proving checkpoint refs and projections are complete before completion signals resolve - - replacement of polling-based checkpoint/integration waits with receipt-based waits -- Web: - - transport tests for invalid JSON, invalid envelope, invalid payload, reconnect queue flushing, cached semantic state -- Validation gate: - - `bun run lint` - - `bun run typecheck` - - `mise exec -- bun run test` - - repeated full-suite run after cutover to confirm flake removal - -## Assumptions and Defaults -- This remains a single-provider product during the cutover, but the runtime contracts must stay provider-neutral. -- No backward-compatibility layer is required for old websocket push envelopes. -- The goal is deterministic runtime behavior first; reducing retries and sleeps in tests is a consequence, not the primary mechanism. -- If a completion signal cannot be expressed provider-neutrally, it does not belong in the shared runtime layer and must stay adapter-local. diff --git a/.plans/18-server-auth-model.md b/.plans/18-server-auth-model.md deleted file mode 100644 index 9f8ba8a05df1..000000000000 --- a/.plans/18-server-auth-model.md +++ /dev/null @@ -1,823 +0,0 @@ -# Server Auth Model Plan - -## Purpose - -Define the long-term server auth architecture for T3 Code before first-class remote environments ship. - -This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods. - -This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code. - -## Primary goals - -- Make auth server-wide, not WebSocket-only. -- Make insecure exposure hard to do accidentally. -- Preserve zero-login local desktop UX for desktop-managed environments. -- Support browser-native pairing and session auth. -- Leave room for native/mobile credentials later without rewriting the server boundary. -- Keep auth separate from transport and launch method. - -## Non-goals - -- Full multi-user authorization and RBAC. -- OAuth / SSO / enterprise identity. -- Passkeys or biometric UX in v1. -- Syncing auth state across environments. -- Designing the full remote environment product in this document. - -## Core decisions - -### 1. Auth is a server concern - -Every privileged surface of the T3 server must go through the same auth policy engine: - -- HTTP routes -- WebSocket upgrades -- RPC methods reached through WebSocket - -The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product. - -### 2. Pairing and session are different things - -The system should distinguish: - -- bootstrap credentials -- session credentials - -Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated. - -Session credentials are the durable credentials used after pairing. - -Bootstrap should never become the long-lived request credential. - -### 3. Auth and transport are separate - -Auth must not be defined by how the client reached the server. - -Examples: - -- local desktop-managed server -- LAN `ws://` -- public `wss://` -- tunneled `wss://` -- SSH-forwarded `ws://127.0.0.1:` - -All of these should feed into the same auth model. - -### 4. Exposure level changes defaults - -The more exposed an environment is, the narrower the safe default should be. - -Safe default expectations: - -- local desktop-managed: auto-pair allowed -- loopback browser access: explicit bootstrap allowed -- non-loopback bind: auth required -- tunnel/public endpoint: auth required, explicit enablement required - -### 5. Browser and native clients may use different session credentials - -The auth model should support more than one session credential type even if only one ships first. - -Examples: - -- browser session cookie -- native bearer/device token - -This should be represented in the model now, even if browser cookies are the first implementation. - -## Target auth domain - -### Route classes - -Every route or transport entrypoint should be classified as one of: - -1. `public` -2. `bootstrap` -3. `authenticated` - -#### `public` - -Unauthenticated by definition. - -Should be extremely small. Examples: - -- static shell needed to render the pairing/login UI -- favicon/assets required for the pairing screen -- a minimal server health/version endpoint if needed - -#### `bootstrap` - -Used only to exchange a bootstrap credential for a session. - -Examples: - -- Initial bootstrap envelope over file descriptor at startup -- `POST /api/auth/bootstrap` -- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX - -#### `authenticated` - -Everything that reveals machine state or mutates it. - -Examples: - -- WebSocket upgrade -- orchestration snapshot and events -- terminal open/write/close -- project search and file writes -- git routes -- attachments -- project favicon lookup -- server settings - -The default stance should be: if it touches the machine, it is authenticated. - -## Credential model - -### Bootstrap credentials - -Initial credential types to model: - -- `desktop-bootstrap` -- `one-time-token` - -Possible future credential types: - -- `device-code` -- `passkey-assertion` -- `external-identity` - -#### `desktop-bootstrap` - -Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments. - -Properties: - -- launcher-provided -- short-lived -- one-time or bounded-use -- never shown to the user as a reusable password - -#### `one-time-token` - -Used for explicit browser/mobile pairing flows. - -Properties: - -- short TTL -- one-time use -- safe to embed in a pairing URL fragment -- exchanged for a session credential - -### Session credentials - -Initial credential types to model: - -- `browser-session-cookie` -- `bearer-session-token` - -#### `browser-session-cookie` - -Primary browser credential. - -Properties: - -- signed -- `HttpOnly` -- bounded lifetime -- revocable by server key rotation or session invalidation - -#### `bearer-session-token` - -Reserved for native/mobile or non-browser clients. - -Properties: - -- opaque token, not a bootstrap secret -- long enough lifetime to survive reconnects -- stored in secure client storage when available - -## Auth policy model - -Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics. - -### Policy examples - -#### `DesktopManagedLocalPolicy` - -Default for desktop-managed local server. - -Allowed bootstrap methods: - -- `desktop-bootstrap` - -Allowed session methods: - -- `browser-session-cookie` - -Disabled by default: - -- `one-time-token` -- `bearer-session-token` -- password login -- public pairing - -#### `LoopbackBrowserPolicy` - -Used for browser access on localhost without desktop-managed bootstrap. - -Allowed bootstrap methods: - -- `one-time-token` - -Allowed session methods: - -- `browser-session-cookie` - -#### `RemoteReachablePolicy` - -Used when binding non-loopback or using an explicit remote/tunnel workflow. - -Allowed bootstrap methods: - -- `one-time-token` -- possibly `desktop-bootstrap` when a desktop shell is brokering access - -Allowed session methods: - -- `browser-session-cookie` -- `bearer-session-token` - -#### `UnsafeNoAuthPolicy` - -Should exist only as an explicit escape hatch. - -Requirements: - -- explicit opt-in flag -- loud startup warnings -- never defaulted automatically - -## Effect-native service model - -### `ServerAuth` - -The main auth facade used by HTTP routes and WebSocket upgrade handling. - -Responsibilities: - -- classify requests -- authenticate requests -- authorize bootstrap attempts -- create sessions from bootstrap credentials -- enforce policy by environment mode - -Sketch: - -```ts -export interface ServerAuthShape { - readonly getCapabilities: Effect.Effect; - readonly authenticateHttpRequest: ( - request: HttpServerRequest.HttpServerRequest, - routeClass: RouteAuthClass, - ) => Effect.Effect; - readonly authenticateWebSocketUpgrade: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly exchangeBootstrapCredential: ( - input: BootstrapExchangeInput, - ) => Effect.Effect; -} - -export class ServerAuth extends ServiceMap.Service()( - "t3/ServerAuth", -) {} -``` - -### `BootstrapCredentialService` - -Owns issuance, storage, validation, and consumption of bootstrap credentials. - -Responsibilities: - -- issue desktop bootstrap grants -- issue one-time pairing tokens -- validate TTL and single-use semantics -- consume bootstrap grants atomically - -Sketch: - -```ts -export interface BootstrapCredentialServiceShape { - readonly issueDesktopBootstrap: ( - input: IssueDesktopBootstrapInput, - ) => Effect.Effect; - readonly issueOneTimeToken: ( - input: IssueOneTimeTokenInput, - ) => Effect.Effect; - readonly consume: ( - presented: PresentedBootstrapCredential, - ) => Effect.Effect; -} -``` - -### `SessionCredentialService` - -Owns creation and validation of authenticated sessions. - -Responsibilities: - -- mint cookie sessions -- mint bearer sessions -- validate active session credentials -- revoke sessions if needed later - -Sketch: - -```ts -export interface SessionCredentialServiceShape { - readonly createBrowserSession: ( - input: CreateSessionFromBootstrapInput, - ) => Effect.Effect; - readonly createBearerSession: ( - input: CreateSessionFromBootstrapInput, - ) => Effect.Effect; - readonly authenticateCookie: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly authenticateBearer: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; -} -``` - -### `ServerAuthPolicy` - -Pure policy/config service that decides which credential types are allowed. - -Responsibilities: - -- map runtime mode and bind/exposure settings to allowed auth methods -- answer whether a route can be public -- answer whether remote exposure requires auth - -This should stay mostly pure and cheap to test. - -### `ServerSecretStore` - -Owns long-lived server signing keys and secrets. - -Responsibilities: - -- get or create signing key -- rotate signing key -- abstract secure OS-backed storage vs filesystem fallback - -Important: - -- prefer platform secure storage when available -- support hardened filesystem fallback for headless/server-only environments - -### `BrowserSessionCookieCodec` - -Focused utility service for cookie encode/decode/signing behavior. - -This should not own policy. It should only own the cookie format. - -### `AuthRouteGuards` - -Thin helper layer used by routes to enforce auth consistently. - -Responsibilities: - -- require auth for HTTP route handlers -- classify route auth mode -- convert auth failures into `401` / `403` - -This prevents every route from re-implementing the same pattern. - -Integrates with `HttpRouter.middleware` to enforce auth consistently. - -## Suggested layer graph - -```text -ServerSecretStore - ├─> BootstrapCredentialService - ├─> BrowserSessionCookieCodec - └─> SessionCredentialService - -ServerAuthPolicy - ├─> BootstrapCredentialService - ├─> SessionCredentialService - └─> ServerAuth - -ServerAuth - └─> AuthRouteGuards -``` - -Layer naming should follow existing repo style: - -- `ServerSecretStoreLive` -- `BootstrapCredentialServiceLive` -- `SessionCredentialServiceLive` -- `ServerAuthPolicyLive` -- `ServerAuthLive` -- `AuthRouteGuardsLive` - -## High-level implementation examples - -### Example: WebSocket upgrade auth - -Current state: - -- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts) - -Target shape: - -```ts -const websocketUpgradeAuth = HttpMiddleware.make((httpApp) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* ServerAuth; - yield* serverAuth.authenticateWebSocketUpgrade(request); - return yield* httpApp; - }), -); -``` - -Then the `/ws` route becomes: - -```ts -export const websocketRpcRouteLayer = HttpRouter.add( - "GET", - "/ws", - rpcWebSocketHttpEffect.pipe( - websocketUpgradeAuth, - Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), - ), -); -``` - -This keeps the route itself declarative and makes auth compose like normal HTTP middleware. - -### Example: authenticated HTTP route - -For routes like attachments or project favicon: - -```ts -const authenticatedRoute = (routeClass: RouteAuthClass) => - HttpMiddleware.make((httpApp) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* ServerAuth; - yield* serverAuth.authenticateHttpRequest(request, routeClass); - return yield* httpApp; - }), - ); -``` - -Then: - -```ts -export const attachmentsRouteLayer = HttpRouter.add( - "GET", - `${ATTACHMENTS_ROUTE_PREFIX}/*`, - serveAttachment.pipe( - authenticatedRoute(RouteAuthClass.Authenticated), - Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), - ), -); -``` - -### Example: desktop bootstrap exchange - -The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel. - -That grant is then exchanged for a browser cookie session when the renderer loads. - -Sketch: - -```ts -const pairDesktopRenderer = Effect.gen(function* () { - const bootstrapService = yield* BootstrapCredentialService; - const credential = yield* bootstrapService.issueDesktopBootstrap({ - audience: "desktop-renderer", - ttlMs: 30_000, - }); - return credential; -}); -``` - -The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid. - -### Example: one-time pairing URL - -For browser-driven pairing: - -```ts -const createPairingToken = Effect.gen(function* () { - const bootstrapService = yield* BootstrapCredentialService; - return yield* bootstrapService.issueOneTimeToken({ - ttlMs: 5 * 60_000, - audience: "browser", - }); -}); -``` - -The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it. - -## Sequence diagrams - -These flows are meant to anchor the auth model in concrete user journeys. - -The important invariant across all of them is: - -- access method is not the auth method -- launch method is not the auth method -- bootstrap credential is not the session credential - -### Normal desktop user - -This is the default desktop-managed local flow. - -The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie. - -```text -Participants: - DesktopMain = Electron main - SecretStore = secure local secret backend - T3Server = local backend child process - Frontend = desktop renderer - -DesktopMain -> SecretStore : getOrCreate("server-signing-key") -SecretStore --> DesktopMain : signing key available - -DesktopMain -> T3Server : spawn server (--bootstrap-fd ...) -DesktopMain -> T3Server : send desktop bootstrap envelope -note over T3Server : policy = DesktopManagedLocalPolicy -note over T3Server : allowed pairing = desktop-bootstrap only - -Frontend -> DesktopMain : request local bootstrap grant -DesktopMain --> Frontend : short-lived desktop bootstrap grant - -Frontend -> T3Server : POST /api/auth/bootstrap -T3Server -> T3Server : validate desktop bootstrap grant -T3Server -> T3Server : create browser session -T3Server --> Frontend : Set-Cookie: session=... - -Frontend -> T3Server : GET /ws + authenticated cookie -T3Server -> T3Server : validate cookie session -T3Server --> Frontend : websocket accepted -``` - -### `npx t3` user - -This is the standalone local server flow. - -There is no trusted desktop shell here, so pairing should be explicit. - -```text -Participants: - UserShell = npx t3 launcher - T3Server = standalone local server - Browser = browser tab - -UserShell -> T3Server : start server -T3Server -> T3Server : getOrCreate("server-signing-key") -note over T3Server : policy = LoopbackBrowserPolicy - -UserShell -> T3Server : issue one-time pairing token -T3Server --> UserShell : pairing URL or pairing token - -UserShell --> Browser : open /pair?token=... - -Browser -> T3Server : GET /pair?token=... -T3Server -> T3Server : validate one-time token -T3Server -> T3Server : create browser session -T3Server --> Browser : Set-Cookie: session=... -T3Server --> Browser : redirect to app - -Browser -> T3Server : GET /ws + authenticated cookie -T3Server --> Browser : websocket accepted -``` - -### Phone user with tunneled host - -This is the explicit remote access flow for a browser on another device. - -The tunnel only provides reachability. It must not imply trust. - -Recommended UX: - -- desktop shows a QR code -- desktop also shows a copyable pairing URL -- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access - -```text -Participants: - DesktopUser = user at the host machine - DesktopMain = desktop app - Tunnel = tunnel provider - T3Server = T3 server - PhoneBrowser = mobile browser - -DesktopUser -> DesktopMain : enable remote access via tunnel -DesktopMain -> T3Server : switch policy to RemoteReachablePolicy -DesktopMain -> Tunnel : publish local T3 endpoint -Tunnel --> DesktopMain : public https/wss URL - -DesktopMain -> T3Server : issue one-time pairing token -T3Server --> DesktopMain : pairing token -DesktopMain -> DesktopUser : show QR code / shareable URL - -DesktopUser -> PhoneBrowser : scan QR / open URL -PhoneBrowser -> Tunnel : GET https://public-host/pair?token=... -Tunnel -> T3Server : forward request -T3Server -> T3Server : validate one-time token -T3Server -> T3Server : create mobile browser session -T3Server --> PhoneBrowser : Set-Cookie: session=... -T3Server --> PhoneBrowser : redirect to app - -PhoneBrowser -> Tunnel : GET /ws + authenticated cookie -Tunnel -> T3Server : forward websocket upgrade -T3Server --> PhoneBrowser : websocket accepted -``` - -### Phone user with private network - -This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale. - -The auth flow should stay the same. - -```text -Participants: - DesktopUser = user at the host machine - T3Server = T3 server - PrivateNet = tailscale / private LAN - PhoneBrowser = mobile browser - -DesktopUser -> T3Server : enable private-network access -T3Server -> T3Server : switch policy to RemoteReachablePolicy -DesktopUser -> T3Server : issue one-time pairing token -T3Server --> DesktopUser : pairing URL / QR - -DesktopUser -> PhoneBrowser : open private-network URL -PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=... -PrivateNet -> T3Server : route request -T3Server -> T3Server : validate one-time token -T3Server -> T3Server : create mobile browser session -T3Server --> PhoneBrowser : Set-Cookie: session=... -T3Server --> PhoneBrowser : redirect to app - -PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie -PrivateNet -> T3Server : websocket upgrade -T3Server --> PhoneBrowser : websocket accepted -``` - -### Desktop user adding new SSH hosts - -SSH should be treated as launch and reachability plumbing, not as the long-term auth model. - -The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment. - -```text -Participants: - DesktopUser = local desktop user - DesktopMain = desktop app - SSH = ssh transport/session - RemoteHost = remote machine - RemoteT3 = remote T3 server - Frontend = desktop renderer - -DesktopUser -> DesktopMain : add SSH host -DesktopMain -> SSH : connect to remote host -SSH -> RemoteHost : probe environment / verify t3 availability -DesktopMain -> SSH : run remote launch command -SSH -> RemoteHost : t3 remote launch --json -RemoteHost -> RemoteT3 : start or reuse server -RemoteT3 --> RemoteHost : port + environment metadata -RemoteHost --> SSH : launch result JSON -SSH --> DesktopMain : remote server details - -DesktopMain -> SSH : establish local port forward -SSH --> DesktopMain : localhost:FORWARDED_PORT ready - -note over RemoteT3 : policy = RemoteReachablePolicy -note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here - -Frontend -> DesktopMain : request bootstrap for selected environment -DesktopMain --> Frontend : short-lived bootstrap grant - -Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port -RemoteT3 -> RemoteT3 : validate bootstrap grant -RemoteT3 -> RemoteT3 : create browser session -RemoteT3 --> Frontend : Set-Cookie: session=... - -Frontend -> RemoteT3 : GET /ws + authenticated cookie -RemoteT3 --> Frontend : websocket accepted -``` - -## Storage decisions - -### Server secrets - -Use a `ServerSecretStore` abstraction. - -Preferred order (use a layer for each, resolve on startup): - -1. OS secure storage if available -2. hardened filesystem fallback if not - -The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials. - -### Client credentials - -Client-side credential persistence should prefer secure storage when available: - -- desktop: OS keychain / secure store -- mobile: platform secure storage -- browser: cookie session for browser auth - -This concern should stay in the client shell/runtime layer, not the server auth layer. - -## What to build now - -These are the parts worth building before remote environments ship: - -1. `ServerAuth` service boundary. -2. route classification and route guards. -3. `ServerSecretStore` abstraction. -4. bootstrap vs session credential split. -5. browser session cookie codec as one session method. -6. explicit auth capabilities/config surfaced in contracts. - -Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained. - -## What to add as part of first remote-capable auth - -1. Browser pairing flow using one-time bootstrap token and cookie session. -2. Desktop-managed auto-bootstrap for the local desktop-managed environment. -3. Auth-required defaults for any non-loopback or explicitly published server. -4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks. - -## What to defer - -- passkeys / WebAuthn -- iCloud Keychain / Face ID-specific UX -- multi-user permissions -- collaboration roles -- OAuth / SSO -- polished session management UI -- complex device approval flows - -These can all sit on top of the same bootstrap/session/service split. - -## Relationship to future remote environments - -Remote access is one reason this auth model matters, but the auth model should not be remote-shaped. - -Keep the design focused on: - -- one T3 server -- one auth policy -- multiple credential types -- multiple future access methods - -That keeps the server auth model stable even as access methods expand later. - -## Recommended implementation order - -### Phase 1 - -- Introduce route auth classes. -- Add `ServerAuth` and `AuthRouteGuards`. -- Move existing `authToken` check behind `ServerAuth`. -- Require auth for all privileged HTTP routes as well as WebSocket. - -### Phase 2 - -- Add `ServerSecretStore` service with platform-specific layer implementations. - - `layerOSXKeychain`, `layer -- Add bootstrap/session split. -- Add browser session cookie support. -- Add one-time bootstrap exchange endpoint. - -### Phase 3 - -- Add desktop bootstrap flow on top of the same services. -- Make desktop-managed local environments default to bootstrap-only pairing. -- Surface auth capabilities in shared contracts and renderer bootstrap. - -### Phase 4 - -- Add non-browser bearer session support if mobile/native needs it. -- Add richer policy modes for remote-reachable environments. - -## Acceptance criteria - -- No privileged HTTP or WebSocket path bypasses auth policy. -- Local desktop-managed flows still avoid a visible login screen. -- Non-loopback or published environments require explicit authenticated pairing by default. -- Bootstrap and session credentials are distinct in code and in behavior. -- Auth logic is centralized in Effect services/layers rather than route-local branching. diff --git a/.plans/19-remote-endpoints-hosted-static.md b/.plans/19-remote-endpoints-hosted-static.md deleted file mode 100644 index ada2f681ce4a..000000000000 --- a/.plans/19-remote-endpoints-hosted-static.md +++ /dev/null @@ -1,349 +0,0 @@ -# Remote Endpoints and Hosted Static App Plan - -## Purpose - -Make remote access feel first-class while keeping the free DIY path open. - -The immediate product goal is: - -- users can expose a backend through LAN, their own Tailscale, MagicDNS, a manual HTTPS endpoint, or later T3 Tunnel -- users can generate a hosted pairing link for `app.t3.codes` -- the hosted app can pair, persist, reconnect, and operate against saved environments without requiring a backend at the hosted app origin -- all transports reuse the same backend auth, WebSocket runtime, saved environment registry, and pairing UX - -This plan intentionally leaves the paid T3 cloud tunnel fabric out of scope. It defines the OSS foundation that T3 Tunnel should later plug into. - -## Current State - -Already present or in progress: - -- Server auth distinguishes bootstrap credentials from session credentials. -- One-time pairing credentials can be exchanged for browser sessions or bearer sessions. -- Saved remote environments store `httpBaseUrl`, `wsBaseUrl`, and a bearer token. -- Remote environment WebSocket connections use a short-lived WebSocket token. -- Pairing URLs can carry tokens in the URL fragment. -- Hosted `/pair?host=...#token=...` can add a saved environment. -- Hosted static startup can avoid assuming the page origin is the backend. - -Main gaps: - -- Reachability is represented ad hoc as `endpointUrl`, manual host input, or saved environment URLs. -- Desktop exposure, hosted pairing, manual remote environments, and future tunnels do not share one endpoint model. -- Tailscale/MagicDNS endpoints are not detected or surfaced. -- Hosted-static empty/offline states are still thin. -- Browser compatibility is not explicitly modeled, especially HTTPS hosted app to HTTP backend mixed-content failure. - -## Core Decision: Add `AdvertisedEndpoint` - -Add a new first-class contract instead of extending the environment descriptor. - -### Why not extend `ExecutionEnvironmentDescriptor` - -`ExecutionEnvironmentDescriptor` answers: "What environment is this?" - -Examples: - -- environment id -- label -- platform -- server version -- capabilities - -`AdvertisedEndpoint` answers: "How can a client reach this environment right now?" - -Examples: - -- loopback URL -- LAN URL -- Tailscale IP URL -- MagicDNS/Serve URL -- manual URL -- future T3 Tunnel URL -- browser compatibility and exposure level - -Those are different lifecycles. One environment can have many endpoints, endpoints can appear/disappear as network interfaces change, and the same descriptor is returned regardless of which endpoint the client used. Extending the descriptor would blur environment identity with transport reachability and make saved environments harder to reason about. - -### Target Contract - -Add a schema in `packages/contracts`, likely `remoteAccess.ts`: - -```ts -type AdvertisedEndpointProvider = - | "loopback" - | "lan" - | "tailscale-ip" - | "tailscale-magicdns" - | "manual" - | "t3-tunnel"; - -type AdvertisedEndpointVisibility = "local" | "private-network" | "tailnet" | "public"; - -type AdvertisedEndpointCompatibility = { - hostedHttpsApp: "compatible" | "mixed-content-blocked" | "untrusted-certificate" | "unknown"; - desktopApp: "compatible" | "unknown"; -}; - -type AdvertisedEndpoint = { - id: string; - provider: AdvertisedEndpointProvider; - label: string; - httpBaseUrl: string; - wsBaseUrl: string; - visibility: AdvertisedEndpointVisibility; - compatibility: AdvertisedEndpointCompatibility; - source: "server" | "desktop" | "user"; - status: "available" | "unavailable" | "unknown"; - isDefault?: boolean; -}; -``` - -Keep the contract schema-only. All classification logic belongs in `packages/shared`, `apps/server`, `apps/desktop`, or `apps/web`. - -## HTTP/WS and HTTPS/WSS Readiness - -The codebase is partially ready, but the UX and compatibility model are not explicit enough. - -What is ready: - -- Remote target parsing already derives `ws://` from `http://` and `wss://` from `https://`. -- Saved environments store both HTTP and WebSocket base URLs. -- Remote auth uses bearer tokens instead of cookies, so cross-origin hosted clients are viable. -- WebSocket connections can use a dynamically issued `wsToken`. -- Server CORS support exists for browser remote auth endpoints. - -What is not solved by code alone: - -- `https://app.t3.codes` cannot reliably call `http://...` or `ws://...` endpoints because browsers block mixed content. -- `wss://100.x.y.z:3773` needs a certificate the browser trusts. A raw Tailscale IP does not solve certificate trust. -- LAN `http://192.168.x.y:3773` is usable from another desktop/native context but not from the hosted HTTPS app. -- The UI needs to explain why an endpoint is copyable for desktop pairing but not hosted-app compatible. - -Policy: - -- Support both HTTP/WS and HTTPS/WSS at the runtime layer. -- Mark endpoint compatibility at the product layer. -- Generate `app.t3.codes` links only from endpoints that are likely hosted-browser compatible, or show a warning with an explicit fallback. - -## Architecture - -### Endpoint Sources - -Endpoint records can come from several providers: - -1. **Server runtime** - - headless bind host and port - - server-known explicit advertised host config - -2. **Desktop shell** - - loopback backend URL - - LAN exposure state - - network interface discovery - - Tailscale CLI/status discovery - -3. **User configuration** - - manually added hostnames - - preferred endpoint labels - - hidden/disabled endpoints - -4. **Future cloud provider** - - T3 Tunnel endpoint - - billing/account status - - tunnel lifecycle state - -### Endpoint Registry - -Create a central runtime registry: - -- `packages/contracts/src/remoteAccess.ts` -- `packages/shared/src/remoteAccess.ts` for URL normalization and compatibility classification -- `apps/server/src/remoteAccess/*` for server/headless endpoints -- `apps/desktop/src/remoteAccess/*` for desktop-discovered endpoints -- `apps/web/src/environments/endpoints/*` for client-side display and pairing selection - -The web app should consume endpoint records and not care whether they came from LAN, Tailscale, or a future tunnel. - -### Pairing Link Generation - -Move hosted pairing link generation to endpoint-driven input: - -```ts -buildHostedPairingUrl({ - endpoint: AdvertisedEndpoint, - token, -}); -``` - -Generated URL: - -```text -https://app.t3.codes/pair?host=#token= -``` - -Use fragment tokens by default. Continue accepting `?token=` for compatibility. - -## Phase 1: Endpoint Abstraction - -### Goals - -- Centralize URL normalization, protocol derivation, and compatibility checks. -- Replace ad hoc desktop `endpointUrl` pairing logic with endpoint selection. -- Preserve all current remote behavior. - -### Tasks - -1. Add `AdvertisedEndpoint` schemas to `packages/contracts`. -2. Add shared helpers: - - normalize HTTP base URL - - derive WebSocket base URL - - classify loopback/private/LAN/Tailscale/public host - - classify hosted HTTPS compatibility -3. Add server endpoint discovery: - - loopback endpoint - - configured non-loopback endpoint - - explicit advertised host override -4. Add desktop endpoint discovery: - - local loopback - - LAN exposure endpoint - - endpoint status labels -5. Add WebSocket/API method or existing config field for endpoint snapshots. -6. Refactor settings connections UI: - - render endpoint rows - - endpoint picker for pairing link copy - - show compatibility warnings -7. Refactor hosted link builder to accept endpoint records. -8. Add tests for URL normalization and compatibility classification. - -### Acceptance Criteria - -- Existing LAN/network access UI still works. -- Pairing links are generated from endpoint records. -- Loopback endpoints never produce hosted pairing links silently. -- HTTP private-network endpoints are marked incompatible with `app.t3.codes`. -- No remote environment runtime changes are required for existing saved environments. - -## Phase 2: BYO Tailscale/MagicDNS - -### Goals - -- Detect free DIY Tailscale reachability. -- Surface Tailscale endpoints as normal advertised endpoints. -- Keep users in control of their own tailnet. - -### Tasks - -1. Detect Tailscale IPs from network interfaces: - - IPv4 `100.64.0.0/10` - - mark as `provider: "tailscale-ip"` -2. Add optional desktop-side `tailscale status --json` discovery: - - MagicDNS hostname - - Tailscale Serve/Funnel HTTPS endpoint if discoverable - - graceful failure if CLI is missing -3. Add manual Tailscale endpoint override: - - hostname - - label - - preferred/default flag -4. Show Tailscale endpoint rows in settings: - - raw IP HTTP endpoint: desktop-compatible, hosted-app likely blocked - - HTTPS MagicDNS/Serve endpoint: hosted-compatible if URL is HTTPS -5. Generate pairing links using selected Tailscale endpoint. -6. Document DIY setup: - - local desktop-to-desktop over Tailscale - - hosted app requirements - - why HTTPS matters - -### Acceptance Criteria - -- A machine on Tailscale shows a Tailscale endpoint without paid features. -- Users can copy a Tailscale-hosted pairing link when the endpoint is HTTPS-compatible. -- Users can still copy token-only/manual values when endpoint compatibility is unknown. -- Tailscale is optional and never required for regular LAN/loopback use. - -## Phase 3: Hosted Static App Completion - -### Goals - -- `app.t3.codes` works as a real client shell. -- It can pair, persist, reconnect, and clearly explain offline/incompatible states. - -### Tasks - -1. Finish hosted-static root behavior: - - no primary backend required - - saved environment hydration before initial routing decisions - - first saved environment selected as active -2. Add hosted empty state: - - no saved environments - - paste pairing URL - - add host + token -3. Add offline saved environment UI: - - last connected - - reconnect - - remove - - copy/add alternate endpoint -4. Audit primary-backend assumptions: - - command palette - - settings pages - - server config atom defaults - - keybindings - - provider/model lists - - update/desktop-only affordances -5. Add route tests for: - - hosted `/pair?host=...#token=...` - - hosted root with no saved environments - - hosted root with saved environment - - primary backend unavailable but saved environment present -6. Add deployment hardening: - - SPA fallback - - strict CSP - - no third-party scripts - - no query token logging - - disable or hide source maps in production if needed -7. Add browser error messages: - - mixed content - - unreachable backend - - CORS failure - - certificate failure - -### Acceptance Criteria - -- `app.t3.codes` can pair a reachable HTTPS backend and reconnect after reload. -- A saved environment can be used without any backend at `app.t3.codes`. -- Offline machines show a useful state instead of a generic boot error. -- HTTP endpoints are still supported in desktop/native/local contexts. -- Hosted HTTPS app only promises compatibility for HTTPS/WSS endpoints. - -## Phase 4: Future T3 Tunnel Provider - -Not part of the current implementation, but the endpoint abstraction should make it straightforward. - -Future tunnel provider responsibilities: - -- create endpoint with `provider: "t3-tunnel"` -- surface tunnel status -- provide stable HTTPS URL -- use existing backend pairing/session auth -- never bypass server auth - -The tunnel fabric can later be Pipenet-derived, Tailscale-derived, or another reverse tunnel implementation. The rest of T3 Code should only see an `AdvertisedEndpoint`. - -## Security Checklist - -- Pairing tokens are short-lived and one-time. -- Generated hosted pairing links put tokens in the fragment. -- The backend remains the authorization boundary. -- Endpoint discovery never disables backend auth. -- Hosted app does not silently downgrade to HTTP. -- Tunnel/public endpoints require explicit user action. -- Client sessions remain revocable. -- Endpoint URLs and request logs must avoid recording pairing tokens. -- Future cloud tunnel must authenticate tunnel creation and tunnel data connections separately from backend pairing. - -## Verification - -Each implementation PR should run: - -- `bun fmt` -- `bun lint` -- `bun typecheck` -- focused tests for changed backend/web behavior -- backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test` diff --git a/.plans/19-version-control-phase-1-vcs-driver-foundation.md b/.plans/19-version-control-phase-1-vcs-driver-foundation.md deleted file mode 100644 index e71c22d0ce31..000000000000 --- a/.plans/19-version-control-phase-1-vcs-driver-foundation.md +++ /dev/null @@ -1,216 +0,0 @@ -# Version Control Phase 1: VCS Driver Foundation - -## Goal - -Introduce a provider-neutral VCS layer and rewrite the local Git implementation as an Effect-native driver. This phase should preserve user-visible behavior while replacing the Git-first service boundary with an abstraction that can support Git, Jujutsu, and later Sapling or another viable VCS. - -The existing `GitCore` implementation is a behavior reference and source of regression tests, not the target architecture. New code should follow the newer package style used by `effect-acp` and `effect-codex-app-server`: typed service tags, schema-backed tagged errors, scoped process usage, explicit decode boundaries, and no Promise-based process helper as the core execution primitive. - -## Scope - -- Add VCS-domain contracts in `packages/contracts/src/vcs.ts`. -- Add shared runtime parsing helpers in `packages/shared/src/vcs/*` only when they are useful to both server and web. -- Add server services under `apps/server/src/vcs`: - - `Services/VcsDriver.ts` - - `Services/VcsRepositoryResolver.ts` - - `Services/VcsProcess.ts` - - `Layers/GitVcsDriver.ts` - - `errors.ts` -- Migrate server callers from Git-specific terms where the operation is actually VCS-generic. -- Update active consumers to the new VCS APIs in the same phase; do not add backwards-compatible export shims. -- Leave source-control hosting providers out of this phase except for remote metadata needed to describe repository status. - -## Non-Goals - -- No GitLab, Azure DevOps, or GitHub provider rewrite yet. -- No Jujutsu driver yet, but every interface must be designed so a Jujutsu driver does not have to pretend to be Git. -- No T3 Review implementation yet. -- No broad UI redesign. - -## Driver Model - -Use provider-neutral nouns in new APIs: - -- `VcsDriver`: local repository mechanics. -- `RepositoryIdentity`: detected VCS kind, root path, common metadata path when available, remotes. -- `WorkingCopyStatus`: dirty state, changed files, aggregate insertions/deletions, current branch/bookmark/change name. -- `ChangeSet`: a committed or pending unit of change, not necessarily a Git commit. -- `RefName`: branch, bookmark, tag, or provider-specific ref. - -The initial driver capabilities should be explicit: - -```ts -export interface VcsDriverCapabilities { - readonly kind: "git" | "jj" | "sapling" | "unknown"; - readonly supportsWorktrees: boolean; - readonly supportsBookmarks: boolean; - readonly supportsAtomicSnapshot: boolean; - readonly supportsPushDefaultRemote: boolean; -} -``` - -Do not model Jujutsu as `GitCoreShape extends ...`. The Git driver can expose Git-specific implementation details internally, but the public VCS layer should describe operations by intent: - -- `detectRepository(cwd)` -- `status(cwd, options)` -- `listRefs(cwd, query/pagination)` -- `checkoutRef(cwd, ref)` -- `createRef(cwd, ref, from?)` -- `createWorkspace(cwd, ref, path?)` -- `removeWorkspace(path)` -- `prepareChangeContext(cwd, filePaths?)` -- `createChange(cwd, message, options)` -- `push(cwd, target?)` -- `rangeContext(cwd, base, head)` -- `listWorkspaceFiles(cwd, options)` - -## Effect Process Layer - -Create a small reusable `VcsProcess` service instead of using `runProcess`. - -Requirements: - -- Implement with `ChildProcess` and `ChildProcessSpawner` from `effect/unstable/process`. -- Support scoped acquisition/release for long-running commands and interruption. -- Support bounded stdout/stderr collection with truncation markers. - - DO not eagerly consume full stdout/stderr, return stream apis and expose helpers for consumers so we don't consume streams to memory unnecessarily... -- Support stdin. -- Support timeout through Effect scheduling/interruption, not ad-hoc timers. -- Stream output lines to progress callbacks as Effects. -- Return a typed `ProcessOutput` value for successful execution. -- Fail with typed errors, not generic thrown exceptions. - -Errors should be schema-backed tagged classes, for example: - -- `VcsProcessSpawnError` -- `VcsProcessExitError` -- `VcsProcessTimeoutError` -- `VcsOutputDecodeError` -- `VcsRepositoryDetectionError` -- `VcsUnsupportedOperationError` - -Every error should carry operation name, command display string, cwd when applicable, exit code when applicable, stderr/stdout tails when useful, and original cause where available. Override `message` for user readable messages that provides meaning and hints where appropriate. Errors are schema backed so the full error details will be persisted and serialized properly when stored to DB/Logfiles. - -## Git Driver Rewrite - -Rewrite Git support against `VcsProcess`. - -Carry forward current behavior from: - -- `apps/server/src/git/Layers/GitCore.ts` -- `apps/server/src/git/Layers/GitCore.test.ts` -- current Git status/branch/worktree contracts - -But split the implementation into smaller modules: - -- command execution and hardening config -- repository detection -- status parsing -- branch/ref parsing -- worktree operations -- commit/range context generation -- push/pull operations - -Keep parsing deterministic. Prefer Git porcelain formats, null-separated output, and schema decoding for JSON-like command output. Avoid regex parsing where Git gives a structured format. - -## Freshness and Local Caching - -Define freshness rules in the VCS layer before adding more providers. Local VCS status is cheap enough to refresh often; network-backed status is not. - -Treat these as live/local: - -- repository detection for the active cwd -- working copy dirty state -- staged/unstaged/untracked file summaries -- current branch/bookmark/change name -- local branch/bookmark lists -- local worktree/workspace lists - -These may run on user-visible polling, but should still be debounced and coalesced per repository root. Prefer filesystem-triggered invalidation where available, with a short fallback poll interval. Concurrent requests for the same repository/status shape should share one in-flight Effect. - -Treat these as cached or explicit-refresh only: - -- remote tracking branch refreshes -- ahead/behind counts that require network fetches -- default branch discovery from a remote provider -- remote branch lists beyond locally known refs - -The VCS driver should expose freshness metadata with status results: - -```ts -export interface VcsFreshness { - readonly source: "live-local" | "cached-local" | "cached-remote" | "explicit-remote"; - readonly observedAt: string; - readonly expiresAt?: string; -} -``` - -Remote refreshes should be opt-in per operation, for example `refresh: "local-only" | "allow-cached-remote" | "force-remote"`. The default for background status should be `local-only`. - -Use Effect `Cache` for repository identity and expensive local metadata: - -- key by resolved repository root plus VCS kind -- invalidate on cwd/root changes and workspace mutation operations -- use short TTLs for local status caches when filesystem events are unavailable -- never hide command failures behind stale values unless the caller explicitly accepts stale data - -## Cutover Policy - -Prefer direct migration and deletion over compatibility wrappers. - -Rules: - -- Update consumers to call `VcsDriver`/`VcsRepositoryResolver` directly as soon as the new API exists. -- Delete migrated `GitCore` service methods and tests in the same PR that moves their consumers. -- Do not keep backwards-compatible export shims, barrel aliases, or old service names for convenience. -- Transitional modules are allowed only when a caller group is too complex or risky to migrate in the same PR. -- Every transitional module must have a narrow owner, a removal checklist, and a test proving it delegates to the new implementation. -- No new feature work may depend on transitional modules. - -Expected transitional candidates: - -- The highest-level `GitManager` orchestration can be migrated in slices if doing the full Commit + PR flow in one PR is too risky. -- WebSocket payload compatibility can remain only where changing it would require a coordinated UI/server protocol migration. Internal server code should still use the new VCS contracts. - -## Tests - -Add integration-style tests with real temporary Git repositories for the new Git driver: - -- non-repository detection -- status for clean/dirty/untracked/staged states -- branch/ref list with pagination -- checkout/create branch -- worktree create/remove -- commit context generation with file filters -- commit creation with hook progress events -- push behavior against a local bare remote -- status polling does not perform remote network refresh by default -- concurrent duplicate status requests are coalesced -- bounded output/truncation -- timeout/interruption -- typed error shape for command failure and missing executable - -Move or duplicate only the tests needed to prove behavior, then delete the old service tests in the same migration slice. - -## Migration Steps - -1. Add `vcs` contracts and tagged errors. -2. Add `VcsProcess` and unit tests around process execution semantics. -3. Add `VcsDriver` and `VcsRepositoryResolver` service contracts. -4. Implement `GitVcsDriver` with real Git command integration tests. -5. Move `GitStatusBroadcaster` and branch/worktree flows to the VCS service directly. -6. Move commit/range/push callers to the VCS service directly. -7. Delete migrated `GitCore` internals and tests as each caller group moves. -8. Add a transitional adapter only for any remaining `GitManager` path that is explicitly too complex to cut over safely in one PR. -9. Remove every transitional adapter before starting Phase 2 unless the adapter is documented as blocking on the provider cutover. - -## Acceptance Criteria - -- Current Git branch/status/worktree/commit behavior remains intact. -- New Git implementation does not depend on `processRunner.ts`. -- New errors are typed and inspectable by tests. -- VCS interfaces contain no GitHub/GitLab/Azure concepts. -- Active consumers use the new VCS APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. -- Background status refresh is local-only by default and cannot hit provider rate limits. -- Jujutsu can be added by implementing a real driver instead of conforming to Git command semantics. -- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/20-version-control-phase-2-source-control-provider-foundation.md b/.plans/20-version-control-phase-2-source-control-provider-foundation.md deleted file mode 100644 index ac1186ba5f9b..000000000000 --- a/.plans/20-version-control-phase-2-source-control-provider-foundation.md +++ /dev/null @@ -1,268 +0,0 @@ -# Version Control Phase 2: Source Control Provider Foundation - -## Goal - -Introduce a pluggable source-control provider layer and rewrite GitHub support as an Effect-native provider. This phase should preserve the existing GitHub Commit + PR flow while making GitLab and Azure DevOps additive drivers rather than branches inside GitHub-oriented code. - -The existing `GitHubCli` service and GitHub-specific `GitManager` paths are behavior references. The new provider layer should use detailed tagged errors, schema decode boundaries, `effect/unstable/process`, capability flags, and provider-neutral change-request types. - -## Scope - -- Add provider-domain contracts in `packages/contracts/src/sourceControl.ts`. -- Add provider URL/reference parsing helpers in `packages/shared/src/sourceControl/*`. -- Add server services under `apps/server/src/sourceControl`: - - `Services/SourceControlProvider.ts` - - `Services/SourceControlProviderRegistry.ts` - - `Services/SourceControlProcess.ts` - - `Layers/GitHubSourceControlProvider.ts` - - `errors.ts` -- Migrate PR creation, PR lookup, default-branch lookup, clone URL lookup, and PR checkout through the provider layer. -- Update active consumers to the provider APIs directly; do not add backwards-compatible `GitHubCli` export shims. -- Keep GitHub as the only production provider at the end of this phase, but make GitLab and Azure implementation paths obvious and bounded. - -## Non-Goals - -- No GitLab implementation in this phase, except fixtures/contracts that prove the abstraction can represent merge requests. -- No Azure DevOps implementation in this phase, except URL/reference parser test cases if cheap. -- No in-app review UI yet. -- No hard dependency on one CLI forever. The first GitHub driver may use `gh`, but the interface should support REST/GraphQL implementations later. - -## Provider Model - -Use provider-neutral names: - -- `SourceControlProvider`: hosted repository and change-request mechanics. -- `ChangeRequest`: GitHub pull request, GitLab merge request, Azure pull request. -- `ChangeRequestThread`: review or discussion thread. -- `ChangeRequestComment`: top-level or inline comment. -- `ProviderRepository`: owner/project/repo identity plus clone URLs. - -Core provider operations: - -- `detectRemote(remoteUrl)` -- `checkAuth(cwd)` -- `getRepository(cwd | remoteUrl)` -- `getDefaultTargetRef(repository)` -- `listChangeRequests(repository, filters)` -- `getChangeRequest(repository, reference)` -- `createChangeRequest(repository, input)` -- `checkoutChangeRequest(cwd, changeRequest, options)` -- `getCloneUrls(repository)` - -Review-facing operations should be designed now, even if unimplemented: - -- `listReviewThreads(changeRequest)` -- `createReviewComment(changeRequest, input)` -- `replyToReviewThread(thread, input)` -- `resolveReviewThread(thread)` -- `submitReview(changeRequest, input)` - -Each operation should be guarded by capabilities: - -```ts -export interface SourceControlProviderCapabilities { - readonly kind: "github" | "gitlab" | "azure-devops" | "unknown"; - readonly supportsCreateChangeRequest: boolean; - readonly supportsCheckoutChangeRequest: boolean; - readonly supportsReviewThreads: boolean; - readonly supportsInlineComments: boolean; - readonly supportsDraftChangeRequests: boolean; -} -``` - -## Provider Registry - -Add a registry that resolves a provider from repository remotes and explicit user input. - -Rules: - -- Detection should be pure where possible and testable without spawning CLIs. -- Remote URL parsing belongs in `packages/shared`, not server-only provider layers. -- Unknown providers should return explicit unsupported-operation errors, not silently fall back to GitHub. -- Provider selection should be stable per operation and logged with enough context to debug bad remote detection. - -The registry should support multiple provider implementations at runtime, not a single dispatcher file with inline provider branches. - -## Rate Limits and Provider Caching - -Design the provider layer around a strict freshness budget. Provider API and CLI calls must not be part of frequent background polling unless the operation is explicitly marked safe and cached. - -Default behavior: - -- Pure URL/remote parsing is always live because it is local. -- Provider detection from local remotes is live-local. -- Authentication checks are cached. -- Repository metadata is cached. -- Default branch metadata is cached. -- Change-request lists are cached and refreshed on explicit user actions or coarse intervals. -- Full review threads, comments, file diffs, and timeline data are fetched only when the user opens the relevant review surface or explicitly refreshes it. -- Create/update operations invalidate affected cache keys immediately after success. - -The provider API should make freshness explicit: - -```ts -export interface SourceControlFreshness { - readonly source: "live-local" | "cached-provider" | "live-provider"; - readonly observedAt: string; - readonly expiresAt?: string; - readonly stale?: boolean; -} - -export type ProviderRefreshPolicy = - | "cache-first" - | "stale-while-revalidate" - | "force-refresh" - | "local-only"; -``` - -Every read operation that can touch a provider should accept a refresh policy. Background UI reads should default to `cache-first` or `stale-while-revalidate`; direct user actions like pressing refresh can use `force-refresh`. - -Use Effect `Cache` for provider data: - -- auth status: key by provider kind, hostname, workspace identity, and account if known; TTL around minutes, not seconds -- repository metadata/default branch: key by provider repository stable ID or normalized remote URL; TTL around tens of minutes -- change-request summary lists: key by provider repository, state/filter, source ref, target ref; short TTL with stale-while-revalidate -- individual change-request summaries: key by provider repository and provider CR ID; short TTL, invalidated after create/update/comment operations -- review threads/comments/diffs: key by provider CR ID and head SHA/version when available; fetch on demand for T3 Review - -Provider drivers should surface rate-limit signals when available: - -- remaining quota -- reset time -- retry-after duration -- whether the limit is primary, secondary/abuse, or unknown - -Rate-limit errors should be typed, retryable when the provider gives a reset/retry time, and visible enough for the UI to avoid repeatedly retrying a blocked operation. - -Avoid rate-limit footguns: - -- no provider calls from render loops or fast status polling -- no listing all PRs/MRs across all repos to infer one branch state -- no silent GitHub fallback for unknown providers -- no unbounded cache cardinality for branch names or free-form search queries -- no per-thread duplicate provider refresh when multiple views observe the same repository - -## GitHub Provider Rewrite - -Rewrite GitHub support as `GitHubSourceControlProvider`. - -Carry forward behavior from: - -- `apps/server/src/git/Layers/GitHubCli.ts` -- `apps/server/src/git/Layers/GitHubCli.test.ts` -- `apps/server/src/git/githubPullRequests.ts` -- GitHub-specific `GitManager` PR paths - -Implementation requirements: - -- Use `SourceControlProcess` built on `effect/unstable/process`, not `runProcess`. -- Decode `gh api` and `gh pr --json` responses with Effect Schema. -- Use typed errors for auth failure, missing CLI, command failure, output decode failure, unsupported reference, and provider mismatch. -- Keep stdout/stderr bounded. -- Avoid global mutable auth caches unless they are Effect `Cache` values with explicit keys, TTLs, and invalidation behavior. -- Parse provider rate-limit headers or CLI/API error payloads when available and map them to typed rate-limit errors. -- Keep GitHub nouns inside the GitHub driver; convert to `ChangeRequest` at the provider boundary. - -## GitManager Cutover - -Refactor `GitManager` so it coordinates three independent services: - -- `VcsDriver` for local repository mechanics. -- `SourceControlProviderRegistry` for hosted provider selection. -- `TextGeneration` for message/body generation. - -`GitManager` should stop depending directly on GitHub services. User-visible step labels should be provider-neutral unless the selected provider is known and the label is intentionally provider-specific. - -The Commit + PR flow should become: - -1. Resolve VCS repository and local status. -2. Resolve source-control provider from remotes. -3. Generate commit content through the existing text generation service. -4. Create local change through `VcsDriver`. -5. Push through `VcsDriver` or a narrow provider push helper only if the VCS requires provider-specific target syntax. -6. Generate change-request title/body. -7. Create the change request through `SourceControlProvider`. - -## Cutover Policy - -This phase should aggressively remove old GitHub-specific internals. - -Rules: - -- Move each active consumer directly to `SourceControlProviderRegistry` or a concrete provider test layer. -- Delete migrated `GitHubCli` methods, tests, and GitHub-specific helper exports in the same PR that moves their final consumer. -- Do not add compatibility export shims from `apps/server/src/git` to `apps/server/src/sourceControl`. -- Transitional modules are allowed only for a bounded `GitManager` slice that cannot move safely with the rest of the provider cutover. -- Every transitional module must have an owner comment, a removal checklist, and no public exports consumed by new code. -- Provider-neutral web parsing should replace GitHub-only parsing directly; do not keep parallel parser stacks unless a route still requires both during a single PR. - -## GitLab and Azure Readiness - -Use the triaged references as implementation inputs, not merge targets: - -- GitLab PR #592 is useful for `glab mr` command mapping and JSON normalization. -- Azure issue #1138 defines a good first Azure slice: remote/URL detection and change-request thread setup for same-repo URLs. - -The abstraction should let Phase 3 add: - -- `GitLabSourceControlProvider` using `glab`. -- `AzureDevOpsSourceControlProvider` using `az repos pr` or REST APIs. - -No provider should need to edit GitHub code to join the registry. - -## T3 Review Design Constraint - -Do not optimize only for creation/checkout. The provider layer must be able to support a future in-app review surface. - -That means contracts should include stable IDs and enough metadata for: - -- file-level diffs -- inline review threads -- resolved/unresolved state -- top-level discussion comments -- pending review submission -- provider URL back-links - -Provider-specific fields can live in a metadata bag, but core review behavior should not require the UI to know whether the backing service is GitHub, GitLab, or Azure DevOps. - -## Tests - -Add tests at three levels: - -- Pure parser tests for GitHub, GitLab, and Azure remote URLs and change-request references. -- Provider unit tests with fake `SourceControlProcess` output and schema decode failures. -- Integration-style GitHub CLI tests only where they can run hermetically or be skipped without hiding unit coverage. - -Required cases: - -- GitHub PR URL, number, and branch-ish references. -- GitLab MR URL/reference parsing. -- Azure DevOps PR URL parsing for same-repo URLs. -- unknown provider returns unsupported-operation errors. -- missing CLI and auth failures produce distinct typed errors. -- invalid CLI JSON fails at decode boundary with useful context. - -## Migration Steps - -1. Add `sourceControl` contracts and provider-neutral schemas. -2. Add shared remote/reference parser helpers and tests. -3. Add `SourceControlProcess` and provider errors. -4. Add provider registry with GitHub-only registration. -5. Implement `GitHubSourceControlProvider` from scratch against the new process layer. -6. Cut GitHub PR operations in `GitManager` over to the provider registry. -7. Replace web PR-reference parsing with provider-neutral parser output while keeping current GitHub UX. -8. Add provider cache metrics and tests for cache hit, stale refresh, invalidation, and rate-limit error mapping. -9. Delete the migrated `GitHubCli` implementation, tests, and GitHub-specific helper exports unless an explicit transitional checklist remains. - -## Acceptance Criteria - -- Existing GitHub Commit + PR and PR checkout flows still work. -- `GitManager` no longer imports or depends on `GitHubCli`. -- Active consumers use source-control provider APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. -- Source-control contracts can represent GitHub PRs, GitLab MRs, and Azure DevOps PRs. -- Unknown/unsupported providers fail explicitly and visibly. -- GitHub command execution does not depend on `processRunner.ts`. -- Background provider reads are cached/coalesced and do not consume provider API quota on every status refresh. -- Rate-limit responses become typed errors with retry/reset metadata where available. -- The provider API includes the review operations needed by future T3 Review work, even if they are capability-gated. -- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/README.md b/.plans/README.md deleted file mode 100644 index 379158d4efdf..000000000000 --- a/.plans/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Maintainability Plans - -1. `01-shared-model-normalization.md` -2. `02-typed-ipc-boundaries.md` -3. `03-split-codex-app-server-manager.md` -4. `04-split-chatview-component.md` -5. `05-zod-persisted-state-validation.md` -6. `06-provider-logstream-lifecycle.md` -7. `07-ci-quality-gates.md` -8. `08-precommit-format-and-lint.md` -9. `09-event-state-test-expansion.md` -10. `10-unify-process-session-abstraction.md` -19. `19-version-control-phase-1-vcs-driver-foundation.md` -20. `20-version-control-phase-2-source-control-provider-foundation.md` diff --git a/.plans/branch-environment-picker-in-chatview-input.md b/.plans/branch-environment-picker-in-chatview-input.md deleted file mode 100644 index 2c1994d2c8d6..000000000000 --- a/.plans/branch-environment-picker-in-chatview-input.md +++ /dev/null @@ -1,74 +0,0 @@ -# Branch/Environment Picker in ChatView Input - -## Summary - -Add a secondary toolbar below the ChatView input area (similar to Codex UI) that lets users select the target branch and environment mode (Local vs New worktree) before sending their first message. - -## UX - -- A toolbar appears **below** the input form (always visible when it's a git repo) -- Two controls: - 1. **Environment mode** (left side): toggles between "Local" and "New worktree" — **locked after first message** (no longer clickable, just shows current mode as label) - 2. **Branch picker** (right side): dropdown showing local branches — **always changeable**, even after messages are sent -- If not a git repo, the toolbar is hidden entirely (thread uses project cwd as-is) - -## Changes - -### 0. Install `@tanstack/react-query` in `apps/renderer` - -Add dependency + wrap app in `QueryClientProvider`. - -### 1. `apps/renderer/src/store.ts` — MODIFY - -Add a new action to the reducer: - -```ts -| { type: "SET_THREAD_BRANCH"; threadId: string; branch: string | null; worktreePath: string | null } -``` - -Reducer case updates `branch` and `worktreePath` on the thread. - -### 2. `apps/renderer/src/components/ChatView.tsx` — MODIFY - -**Fetch branches** via `useQuery`: - -```ts -const branchQuery = useQuery({ - queryKey: ["git-branches", activeProject?.cwd], - queryFn: () => api.git.listBranches({ cwd: activeProject!.cwd }), - enabled: !!activeProject, -}); -``` - -**Local state:** - -- `envMode: "local" | "worktree"` — environment mode (local component state) - -**UI:** Below the `
`, render a toolbar bar (hidden if `!branchQuery.data?.isRepo`): - -- Left side: env mode button ("Local" / "New worktree") — disabled after first message (locked in) -- Right side: branch dropdown from `branchQuery.data.branches` -- Both styled like existing model picker (small text, chevron, dropdown menus) - -**Behavior:** - -- Branch picker is always active — changing branch dispatches `SET_THREAD_BRANCH` immediately -- Env mode is only clickable when `activeThread.messages.length === 0`. After first message, it becomes a static label showing the locked-in mode -- On first send (`onSend`): if `envMode === "worktree"` and a branch is selected, call `api.git.createWorktree` before starting the session, then dispatch `SET_THREAD_BRANCH` with the worktreePath -- `ensureSession` already uses `activeThread.worktreePath ?? activeProject.cwd` - -### Files to modify - -1. `apps/renderer/package.json` — add `@tanstack/react-query` -2. `apps/renderer/src/main.tsx` (or App entry) — wrap in `QueryClientProvider` -3. `apps/renderer/src/store.ts` — add `SET_THREAD_BRANCH` action -4. `apps/renderer/src/components/ChatView.tsx` — branch/env picker UI with `useQuery` - -## Verification - -1. `turbo build` — compiles -2. Create a new thread → branch bar appears below input with "Local" + current branch -3. Change branch in dropdown → branch updates on thread -4. Toggle "New worktree" → send message → worktree created, session uses worktree cwd -5. After first message: env mode label locks to "Worktree" (not clickable), branch picker still works -6. Non-git project → no branch bar shown diff --git a/.plans/effect-atom.md b/.plans/effect-atom.md deleted file mode 100644 index ff6894f5637e..000000000000 --- a/.plans/effect-atom.md +++ /dev/null @@ -1,89 +0,0 @@ -# Replace React Query With AtomRpc + Atom State - -## Summary -- Use `effect/unstable/reactivity/AtomRpc` over the existing `WsRpcGroup`; stop wrapping RPC in promises via [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts) and [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts). -- Keep Zustand for orchestration read model and UI state. -- Keep a narrow `desktopBridge` adapter for dialogs, menus, external links, theme, and updater APIs. -- Do not introduce Suspense in this migration. Atom-backed hooks should keep returning `data`, `error`, `isLoading|isPending`, `refresh`, and `mutateAsync`-style surfaces so component churn stays low. - -## Target Architecture -- Extract the websocket `RpcClient.Protocol` layer from [wsTransport.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsTransport.ts) into `rpc/protocol.ts`. -- Define one `AtomRpc.Service` for `WsRpcGroup` in `rpc/client.ts`. -- Add `rpc/invalidation.ts` with explicit scoped invalidation keys: `git:${cwd}`, `project:${cwd}`, `checkpoint:${threadId}`, `server-config`. -- Add `platform/desktopBridge.ts` as the only browser/desktop facade. -- Remove from web by the end: [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts), [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts), [wsNativeApiState.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiState.ts), [wsNativeApiAtoms.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiAtoms.tsx), [wsRpcClient.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsRpcClient.ts), and all `*ReactQuery.ts` modules. - -## Phase 1: Infrastructure First -1. Extract the shared websocket RPC protocol layer from [wsTransport.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsTransport.ts) without changing behavior. -2. Build the AtomRpc client on top of that layer. -3. Add one temporary `runRpc` helper for imperative handlers that still want `Promise` ergonomics; it must call the AtomRpc service directly and must not reintroduce a facade object. -4. Replace manual registry wiring with one app-level registry provider based on `@effect/atom-react`. -5. Land this as a no-behavior-change PR. - -## Phase 2: Replace `wsNativeApi`-Owned Push State -1. Migrate welcome/config/provider/settings state first, because it is already atom-shaped and is the lowest-risk way to delete `wsNativeApi` responsibilities. -2. Replace [wsNativeApiState.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiState.ts) with `rpc/serverState.ts`, updated directly from `subscribeServerLifecycle` and `subscribeServerConfig`. -3. Keep the current hook names for one PR: `useServerConfig`, `useServerSettings`, `useServerProviders`, `useServerKeybindings`, `useServerWelcomeSubscription`, `useServerConfigUpdatedSubscription`. -4. Move bootstrap side effects out of [wsNativeApiAtoms.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApiAtoms.tsx) into a new root bootstrap component mounted from [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx). -5. Delete the `server.getConfig()` fallback logic from [wsNativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.ts); snapshot fetch now lives beside the stream atoms. - -## Phase 3: Replace React Query Domain By Domain -1. Replace [gitReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/gitReactQuery.ts) first. -2. Add `rpc/gitAtoms.ts` and `rpc/useGit.ts` with `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, and `useGitMutation`. -3. Mutation settlement must invalidate scoped keys, not a global cache. `checkout`, `pull`, `init`, `createWorktree`, `removeWorktree`, `preparePullRequestThread`, and stacked actions invalidate `git:${cwd}`. Worktree create/remove also invalidates `project:${cwd}`. -4. Replace [projectReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/projectReactQuery.ts) second. `useProjectSearchEntries` must preserve current “keep previous results while loading” behavior. -5. Replace [providerReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/providerReactQuery.ts) third. Preserve current checkpoint error normalization and retry/backoff semantics inside the atom effect. Invalidate by `checkpoint:${threadId}`. -6. Defer the desktop updater until the last phase. - -## Phase 4: Move Root Invalidation Off `queryClient` -1. In [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx), remove `QueryClient` usage and replace the throttled `invalidateQueries` block with throttled invalidation helpers. -2. Keep Zustand orchestration/event application unchanged. -3. Map current effects exactly: -- git or checkpoint-affecting orchestration events touch `checkpoint:${threadId}` -- file creation/deletion/restoration touches `project:${cwd}` -- config-affecting server events touch `server-config` - -## Phase 5: Remove Imperative `NativeApi` Usage -1. Create narrow modules instead of a replacement mega-facade: -- `rpc/orchestrationActions.ts` -- `rpc/terminalActions.ts` -- `rpc/gitActions.ts` -- `rpc/projectActions.ts` -- `platform/desktopBridge.ts` -2. Migrate direct [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts) callers by domain, not file-by-file: git-heavy components first, then orchestration/thread actions, then shell/dialog helpers. -3. After the last caller is gone, delete [nativeApi.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/nativeApi.ts) and the `window.nativeApi` fallback entirely. -4. In the final cleanup PR, remove `NativeApi` from [ipc.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/packages/contracts/src/ipc.ts) if nothing outside web still needs it. - -## Phase 6: Remove React Query Completely -1. Delete `@tanstack/react-query` from `apps/web/package.json`. -2. Remove `QueryClientProvider` and router context from [router.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/router.ts) and [__root.tsx](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/routes/__root.tsx). -3. Replace [desktopUpdateReactQuery.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/lib/desktopUpdateReactQuery.ts) with a writable atom plus `desktopBridge.onUpdateState`. -4. Delete the old query-option tests. - -## Public Interfaces And Types -- Preserve the current server-state hook names during the transition. -- Add permanent domain hooks: `useGitStatus`, `useGitBranches`, `useResolvePullRequest`, `useProjectSearchEntries`, `useCheckpointDiff`, `useDesktopUpdateState`. -- Do not expose raw AtomRpc clients to components. -- Do not add Suspense as part of this migration. -- Final boundary is direct RPC for server features plus `desktopBridge` for local desktop features. - -## Test Plan -- Add unit tests for `rpc/serverState.ts`: snapshot bootstrapping, stream replay, provider/settings updates. -- Add unit tests for git/project/checkpoint hooks: loading, error mapping, retry behavior, invalidation, keep-previous-result behavior. -- Update the browser harness in [wsRpcHarness.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/test/wsRpcHarness.ts) to assert direct RPC + atom behavior instead of `__resetNativeApiForTests`. -- Replace [wsNativeApi.test.ts](/Users/julius/.t3/worktrees/codething-mvp/effect-http-router/apps/web/src/wsNativeApi.test.ts), `gitReactQuery.test.ts`, `providerReactQuery.test.ts`, and `desktopUpdateReactQuery.test.ts` with equivalent atom-backed coverage. -- Acceptance scenarios: -- welcome still bootstraps snapshot and navigation -- keybindings toast still responds to config stream updates -- git status/branches refresh after checkout/pull/worktree actions -- PR resolve dialog keeps cached result while typing -- `@` path search refreshes after file mutations and orchestration events -- diff panel refreshes when checkpoints arrive -- desktop updater still reflects push events and button actions - -## Assumptions And Defaults -- Zustand stays in scope; only `react-query` is being removed. -- `desktopBridge` remains the only non-RPC boundary. -- The migration lands as 5-6 small PRs, each green independently. -- Invalidations are explicit and scoped; do not recreate a global cache client abstraction. -- Orchestration recovery/order logic stays as-is; only the data-fetching and mutation layer changes. diff --git a/.plans/git-flows-integration-tests.md b/.plans/git-flows-integration-tests.md deleted file mode 100644 index 70e233a00860..000000000000 --- a/.plans/git-flows-integration-tests.md +++ /dev/null @@ -1,99 +0,0 @@ -# Git Flows Integration Tests - -## Overview - -Real integration tests that run actual git commands against temporary repos. No mocking. - -## Step 1: Extract git functions into `apps/desktop/src/git.ts` - -The git functions (`listGitBranches`, `createGitWorktree`, `removeGitWorktree`, `createGitBranch`, `checkoutGitBranch`, `initGitRepo`) and their helper `runTerminalCommand` are currently private in `main.ts`. Extract them into a new `apps/desktop/src/git.ts` module with named exports. - -`main.ts` will import and re-use them — no behavior change, just moving code. - -**Files modified:** - -- `apps/desktop/src/git.ts` — new file with all git functions exported -- `apps/desktop/src/main.ts` — import from `./git` instead of defining inline - -## Step 2: Create `apps/desktop/src/git.test.ts` - -Integration tests using real temp git repos. Each test group creates a fresh temp directory with `git init`, makes commits, creates branches as needed, and cleans up after. - -### Setup/teardown pattern - -```ts -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - listGitBranches, - createGitBranch, - checkoutGitBranch, - createGitWorktree, - removeGitWorktree, - initGitRepo, -} from "./git"; - -// Helper: run a raw git command in a dir (for test setup, not under test) -// Helper: create an initial commit (git needs at least one commit for branches) -``` - -### Test groups - -**1. initGitRepo** - -- Creates a valid git repo in a temp dir -- listGitBranches reports `isRepo: true` after init - -**2. listGitBranches** - -- Returns `isRepo: false` for non-git directory -- Returns the current branch with `current: true` -- Sorts current branch first -- Lists multiple branches after creating them -- `isDefault` is false when no remote (no origin/HEAD) - -**3. checkoutGitBranch** - -- Checks out an existing branch (current flag moves) -- Throws when branch doesn't exist -- Throws when checkout would overwrite uncommitted changes (dirty working tree) - -**4. createGitBranch** - -- Creates a new branch (appears in listGitBranches) -- Throws when branch already exists - -**5. createGitWorktree + removeGitWorktree** - -- Creates a worktree directory at the expected path -- Worktree has the correct branch checked out -- Throws when branch is already checked out in another worktree -- removeGitWorktree cleans up the worktree - -**6. Full flow: local branch checkout** - -- init → commit → create branch → checkout → verify current - -**7. Full flow: worktree creation from selected branch** - -- init → commit → create branch → create worktree → verify worktree dir exists and has correct branch - -**8. Full flow: thread switching simulation** - -- init → commit → create branch-a, branch-b → checkout a → checkout b → checkout a → verify current matches - -**9. Full flow: checkout conflict** - -- init → commit → create branch → modify file (unstaged) → checkout other branch → expect error - -## Verification - -```bash -# Run the git integration tests -cd apps/desktop && bun run test - -# Or just the git test file -npx vitest run apps/desktop/src/git.test.ts -``` diff --git a/.plans/git-flows-test-plan.md b/.plans/git-flows-test-plan.md deleted file mode 100644 index 45b86b622b5f..000000000000 --- a/.plans/git-flows-test-plan.md +++ /dev/null @@ -1,103 +0,0 @@ -# Git Flows Test Plan - -## Overview - -Add tests for git branch/worktree flows. Two files: - -1. **Extend** `apps/renderer/src/store.test.ts` — reducer tests for `SET_THREAD_BRANCH` -2. **Create** `apps/renderer/src/git-flows.test.ts` — flow logic tests - -All tests are pure Vitest unit tests (no React rendering). They test the reducer directly and simulate handler logic via sequential reducer dispatches + mocked API calls. - -## File 1: `apps/renderer/src/store.test.ts` (extend) - -Add `describe("SET_THREAD_BRANCH reducer")` with 6 tests: - -- Sets branch + worktreePath atomically -- Clears both to null -- Updates branch while preserving worktreePath -- Does not affect other threads (multi-thread state) -- No-op for nonexistent thread id -- Does not mutate messages, error, or session fields - -Uses existing `makeThread`, `makeState` factories. - -## File 2: `apps/renderer/src/git-flows.test.ts` (new) - -### Factories - -- `makeThread()`, `makeState()`, `makeSession()` — same pattern as store.test.ts -- `makeBranch()` — creates `GitBranch` objects -- `makeMessage()` — creates `ChatMessage` objects -- `makeGitApi()` — returns `{ checkout, createWorktree, createBranch, listBranches }` with `vi.fn()` mocks - -### Test groups (~30 tests total) - -**1. Local branch checkout flow** (2 tests) - -- Successful checkout → SET_THREAD_BRANCH updates branch -- Checkout failure → SET_ERROR, branch unchanged - -**2. Thread branch conflict on send** (3 tests) - -- Two threads maintain independent branch state after SET_ACTIVE_THREAD -- Branch state preserved through multiple thread switches + updates -- Checkout failure on thread switch sets error only on target thread - -**3. Worktree creation on send** (5 tests) - -- First message in worktree mode → createWorktree → SET_THREAD_BRANCH with worktreePath -- No worktree when messages already exist -- No worktree in local envMode -- No worktree when worktreePath already set -- createWorktree failure → SET_ERROR, send aborted, no messages pushed - -**4. Env mode locking** (4 tests) - -- envLocked=false when no messages -- envLocked=true with messages -- Transitions false→true after PUSH_USER_MESSAGE -- Remains true after SET_ERROR and UPDATE_SESSION - -**5. Auto-fill current branch** (3 tests) - -- Dispatches SET_THREAD_BRANCH when thread has no branch and current branch exists -- Does not overwrite existing branch -- No-op when no branch is marked current - -**6. Default branch detection** (2 tests) - -- isDefault flag on branch objects -- current and isDefault can be on different branches - -**7. Branch creation + checkout** (3 tests) - -- Successful create + checkout updates branch -- createBranch failure → error, branch unchanged -- checkout failure after successful create → error, branch unchanged - -**8. Session CWD resolution** (3 tests) - -- Uses worktreePath when available -- cwdOverride takes precedence over worktreePath -- Falls back to project cwd when no worktree - -**9. Error handling patterns** (4 tests) - -- SET_ERROR sets error on correct thread -- SET_ERROR with null clears error -- Error on one thread doesn't affect others -- Error cleared before successful branch operations - -## Verification - -```bash -# Run all renderer tests -cd apps/renderer && bun run test - -# Run just the new test file -npx vitest run apps/renderer/src/git-flows.test.ts - -# Run just the store tests -npx vitest run apps/renderer/src/store.test.ts -``` diff --git a/.plans/git-integration-branch-picker-worktrees.md b/.plans/git-integration-branch-picker-worktrees.md deleted file mode 100644 index b5b5e82e3284..000000000000 --- a/.plans/git-integration-branch-picker-worktrees.md +++ /dev/null @@ -1,115 +0,0 @@ -# Git Integration: Branch Picker + Worktrees - -## Summary - -Add git integration to let users start new threads from a specific branch, optionally creating a git worktree for isolated agent work. - -## UX Flow - -- **Left click** "+ New thread" → immediately creates a thread (current behavior, unchanged) -- **Right click** "+ New thread" → opens a context menu with git options: - - List of local branches → clicking one creates a thread on that branch (uses project cwd) - - Each branch has a "worktree" sub-option → creates a worktree, then creates thread with worktree as cwd -- When thread has a worktree, the agent session uses the worktree path as its cwd -- If git fails (not a repo), context menu shows "Not a git repository" disabled item - -## Changes - -### 1. `packages/contracts/src/git.ts` — CREATE - -New Zod schemas and types: - -- `gitListBranchesInputSchema` — `{ cwd: string }` -- `gitCreateWorktreeInputSchema` — `{ cwd: string, branch: string, path?: string }` -- `gitRemoveWorktreeInputSchema` — `{ cwd: string, path: string }` -- `gitBranchSchema` — `{ name: string, current: boolean }` -- Result types for each - -### 2. `packages/contracts/src/ipc.ts` — MODIFY - -- Add 3 IPC channels: `git:list-branches`, `git:create-worktree`, `git:remove-worktree` -- Add `git` namespace to `NativeApi` with `listBranches`, `createWorktree`, `removeWorktree` - -### 3. `packages/contracts/src/index.ts` — MODIFY - -- Add `export * from "./git"` - -### 4. `apps/desktop/src/main.ts` — MODIFY - -Add 3 IPC handlers + helper functions: - -- `listGitBranches()` — runs `git branch --no-color`, parses output into `{ name, current }[]` -- `createGitWorktree()` — runs `git worktree add `, defaults path to `../{repo}-worktrees/{branch}` -- `removeGitWorktree()` — runs `git worktree remove ` - -Reuses existing `runTerminalCommand()`. - -### 5. `apps/desktop/src/preload.ts` — MODIFY - -Add `git` namespace with 3 `ipcRenderer.invoke` calls. - -### 6. `apps/renderer/src/types.ts` — MODIFY - -Add to `Thread`: - -``` -branch: string | null -worktreePath: string | null -``` - -### 7. `apps/renderer/src/persistenceSchema.ts` — MODIFY - -- Add optional `branch`/`worktreePath` to persisted thread schema (`.nullable().optional()` for backwards compat) -- Add V3 schema, update union -- Update `hydrateThread` to default new fields to `null` -- Update `toPersistedState` to serialize new fields - -### 8. `apps/renderer/src/store.ts` — MODIFY - -- Update persisted state key to v3, keep v2 as legacy fallback - -### 9. `apps/renderer/src/components/Sidebar.tsx` — MODIFY (main UI work) - -- Keep existing left-click `handleNewThread` unchanged (immediate thread creation) -- Add `onContextMenu` handler to "+ New thread" buttons (both global and per-project) -- On right-click: fetch branches via `api.git.listBranches`, show a custom context menu -- Context menu items: branch names, each with a nested option to create with worktree -- Clicking a branch → creates thread with `branch` set, title = branch name -- Clicking "with worktree" → calls `api.git.createWorktree` first, then creates thread with `worktreePath` -- Show branch badge on thread list items -- If not a git repo, show "Not a git repository" as disabled menu item - -Context menu component: a positioned `
` with `position: fixed` anchored to the click position, dismissed on click-outside or Escape. Follows the existing dropdown pattern from ChatView's model picker. - -### 10. `apps/renderer/src/components/ChatView.tsx` — MODIFY - -- Line 157: use `activeThread.worktreePath ?? activeProject.cwd` as session cwd -- Show branch/worktree badge in header bar - -## Implementation Order - -1. `packages/contracts/src/git.ts` (new schemas) -2. `packages/contracts/src/ipc.ts` + `index.ts` (wire up channels) -3. `apps/desktop/src/main.ts` (git command handlers) -4. `apps/desktop/src/preload.ts` (bridge methods) -5. `apps/renderer/src/types.ts` (Thread type update) -6. `apps/renderer/src/persistenceSchema.ts` + `store.ts` (persistence migration) -7. `apps/renderer/src/components/Sidebar.tsx` (branch picker UI) -8. `apps/renderer/src/components/ChatView.tsx` (worktree cwd + badge) - -## Edge Cases - -- **Not a git repo**: `git branch` fails → context menu shows "Not a git repository" disabled item -- **Branch has slashes**: `feature/foo` → worktree dir becomes `feature-foo` -- **Worktree exists**: git error surfaces to user via inline error message in context menu -- **No persistence breakage**: `.nullable().optional()` fields parse fine with old data - -## Verification - -1. `turbo build` — confirm contracts/desktop/renderer all compile -2. Launch app, add a project pointing to a git repo -3. Click "+ New thread" → verify branch list loads -4. Select a branch, click Start → thread created with branch in title -5. Enable worktree checkbox, pick branch, Start → verify worktree directory created on disk -6. Send a message in worktree thread → verify agent runs in worktree cwd -7. Add a non-git project → verify graceful error, can still create thread diff --git a/.plans/spec-1-1-cutover-plan.md b/.plans/spec-1-1-cutover-plan.md deleted file mode 100644 index 7345995f1e8c..000000000000 --- a/.plans/spec-1-1-cutover-plan.md +++ /dev/null @@ -1,252 +0,0 @@ -# Spec 1:1 Cutover Plan - -Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft. - -Execution mode for this plan: - -- Hard cutover only. Existing DB and migration history are disposable. -- Intermediate steps are allowed to break runtime, tests, typecheck, and lint. -- We optimize for small, reviewable work units, not continuous app operability. -- Only the final gate requires everything to run cleanly. - -## 1. Freeze SPEC contract as source of truth - -Work units: - -- Create `.plans/spec-contract-matrix.md` with one row per requirement in `SPEC.md` sections `7.1`-`7.4`. -- Add exact SQL-level requirements per row: table, column, type, nullability, PK/unique, index, and invariants. -- Add app-level requirements per row: writer path, reader path, and owning module. -- Mark each row with status labels: `required`, `implemented`, `to-replace`, `delete`. -- Identify any ambiguous spec lines and record a concrete interpretation in the matrix. - -Deliverables: - -- Complete matrix file with no unclassified rows. -- Single source checklist used by all later steps. - -Breakage allowed: - -- No code changes required yet. - -Exit criteria: - -- Every requirement in `7.1`-`7.4` has exactly one matrix row. - -## 2. Hard cutover migrations (replace current migration set) - -Work units: - -- Delete the current legacy migration files and rewrite migration loader ordering. -- Create `001_orchestration_events.ts` with full envelope columns and required event indexes. -- Create `002_orchestration_command_receipts.ts` with PK + lookup indexes. -- Create `003_checkpoint_diff_blobs.ts` with uniqueness on `(thread_id, from_turn_count, to_turn_count)`. -- Create `004_provider_session_runtime.ts` with PK and runtime lookup indexes. -- Create `005_projections.ts` with all projection tables: - - `projection_projects` - - `projection_threads` - - `projection_thread_messages` - - `projection_thread_activities` - - `projection_thread_sessions` - - `projection_thread_turns` - - `projection_checkpoints` - - `projection_pending_approvals` - - `projection_state` -- Add all required indexes/constraints in `005_projections.ts`. -- Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated. - -Deliverables: - -- New 5-file migration chain. -- Updated migration loader references only new migrations. - -Breakage allowed: - -- Repositories/services can be temporarily broken due to removed old tables. - -Exit criteria: - -- Fresh DB initializes with only canonical tables plus migration bookkeeping. - -## 3. Align persistence row/request schemas to DB 1:1 - -Work units: - -- Define row schemas for each canonical table (contracts or persistence layer module). -- Define request schemas for every insert/update/query operation touching canonical tables. -- Remove or deprecate row/request schemas tied to deleted legacy tables. -- Normalize enum and null semantics to match contracts exactly. -- Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms). - -Deliverables: - -- Canonical row/request schemas committed. -- Zero references to legacy row schemas in active code paths. - -Breakage allowed: - -- Runtime can still fail while query layers are being rewired. - -Exit criteria: - -- Every canonical table used in code has a typed row schema and typed request schema. - -## 4. Rewrite event store for full persisted envelope - -Work units: - -- Refactor append path to write full envelope fields: - - `event_id`, `aggregate_kind`, `stream_id`, `stream_version`, `event_type`, `occurred_at`, `command_id`, `causation_event_id`, `correlation_id`, `actor_kind`, `payload_json`, `metadata_json` -- Implement stream version assignment/checking per aggregate stream. -- Refactor read/replay path to decode payload and metadata from JSON and return `OrchestrationEvent` consistently. -- Remove assumptions from old minimal schema (`aggregate_id`, missing metadata/actor). -- Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`). - -Deliverables: - -- Event store append/replay fully aligned with canonical envelope. - -Breakage allowed: - -- Command dispatch flow can be partially broken until receipts/projectors are updated. - -Exit criteria: - -- Event store no longer depends on legacy event table shape. - -## 5. Add command receipt idempotency - -Work units: - -- Introduce persistence access layer for `orchestration_command_receipts`. -- In command dispatch flow, check existing receipt by `commandId` before append. -- On first execution, persist accepted receipt with `resultSequence`. -- On domain rejection, persist rejected receipt with error payload. -- On duplicate command, return prior result from receipt without re-appending event. -- Ensure receipt write and event append ordering is deterministic. - -Deliverables: - -- Dispatch path with idempotency behavior wired through receipts. - -Breakage allowed: - -- Snapshot/read model may still be inconsistent until projectors are fully wired. - -Exit criteria: - -- Duplicate command IDs no longer create duplicate events. - -## 6. Build DB-backed projection pipeline - -Work units: - -- Create projector runner that consumes events and applies table-specific projections. -- Implement projector handlers for each projection table. -- For each handler, update target row(s) and `projection_state.last_applied_sequence` in the same transaction. -- Define projector names used in `projection_state` and make them stable constants. -- Add replay bootstrap from event store to bring projections up to latest sequence on startup. -- Add safe resume logic from projector `last_applied_sequence`. - -Deliverables: - -- Persistent projector pipeline writing all `projection_*` tables. - -Breakage allowed: - -- Web/API layer may still read old in-memory model until step 7. - -Exit criteria: - -- Events drive projection rows in DB; projection state advances transactionally. - -## 7. Move RPC reads to projections and diff blobs - -Work units: - -- Implement snapshot query service reading only projection tables. -- Build thread hydration from projection rows: messages, activities, checkpoints, session. -- Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`. -- Implement `getTurnDiff` query backed by `checkpoint_diff_blobs` only. -- Remove or bypass in-memory snapshot construction for RPC responses. -- Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`. - -Deliverables: - -- `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store. - -Breakage allowed: - -- Provider runtime persistence may still be partially legacy until step 8. - -Exit criteria: - -- No orchestration read RPC depends on legacy tables or in-memory-only state. - -## 8. Migrate provider runtime persistence to canonical table - -Work units: - -- Create repository/service for `provider_session_runtime`. -- Update adapter/session manager to persist runtime/resume cursor in new table. -- Ensure domain-visible session state still flows through orchestration events to `projection_thread_sessions`. -- Remove writes to legacy provider session tables. -- Verify restart/resume path reads runtime state from canonical table only. - -Deliverables: - -- Provider runtime state entirely backed by `provider_session_runtime`. - -Breakage allowed: - -- Some legacy interfaces may still exist but should be disconnected. - -Exit criteria: - -- Runtime restore no longer reads/writes legacy provider session persistence. - -## 9. Remove old cruft aggressively - -Work units: - -- Delete legacy repositories/services that map to removed tables. -- Remove dead migration imports and obsolete persistence service interfaces. -- Remove compatibility code paths that translate legacy row shapes. -- Remove unused contracts/types linked to deprecated persistence model. -- Update internal docs/comments to reference canonical projection/event model only. - -Deliverables: - -- Legacy persistence and translation layers removed from active codebase. - -Breakage allowed: - -- Temporary compile failures acceptable while deletion/refactor is in progress. - -Exit criteria: - -- No production code path references deleted legacy tables/services. - -## 10. Final verification gate (first point where green is required) - -Work units: - -- Add migration tests that assert canonical tables, columns, constraints, and indexes. -- Add event store tests for envelope persistence, metadata, actor kind, and replay. -- Add receipt idempotency tests for accept/reject/duplicate paths. -- Add projector tests for transactional row updates + `projection_state` updates. -- Add snapshot tests verifying projection-sourced output and `snapshotSequence` semantics. -- Add turn diff tests verifying `checkpoint_diff_blobs` source of truth. -- Add provider runtime tests for persist + restart + resume behavior. -- Run project lint/typecheck/tests and fix failures. - -Deliverables: - -- Green checks with canonical schema + persistence model in place. - -Breakage allowed: - -- None at end of step. - -Exit criteria: - -- SPEC `7.1`-`7.4` requirements satisfied and validated by tests. diff --git a/.plans/spec-contract-matrix.md b/.plans/spec-contract-matrix.md deleted file mode 100644 index 7cbb9509a6ad..000000000000 --- a/.plans/spec-contract-matrix.md +++ /dev/null @@ -1,433 +0,0 @@ -# SPEC Contract Matrix (Sections 7.1-7.4) - -Status legend: - -- `required`: requirement acknowledged, no current implementation claim yet. -- `implemented`: requirement currently satisfied in code + schema. -- `to-replace`: partial/misaligned implementation exists and must be replaced. -- `delete`: current path actively conflicts with SPEC and should be removed. - -## 7.1 Write-Side Persisted Tables - -### W1 - -- Spec ref: `7.1.1 orchestration_events` -- Requirement: append-only event store with canonical envelope columns. -- SQL contract: - - `sequence INTEGER PRIMARY KEY` (global monotonic) - - `event_id TEXT UNIQUE NOT NULL` - - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` - - `stream_id TEXT NOT NULL` - - `stream_version INTEGER NOT NULL` - - `event_type TEXT NOT NULL` - - `occurred_at TEXT NOT NULL` - - `command_id TEXT NULL` - - `causation_event_id TEXT NULL` - - `correlation_id TEXT NULL` - - `actor_kind TEXT NOT NULL CHECK IN ('client','server','provider')` - - `payload_json TEXT NOT NULL` - - `metadata_json TEXT NOT NULL` -- Current writer path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` -- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` -- Owner module: `apps/server/src/persistence` (event store + migrations) -- Status: `to-replace` -- Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`. - -### W2 - -- Spec ref: `7.1.2 orchestration_command_receipts` -- Requirement: command idempotency + ack replay receipts table. -- SQL contract: - - `command_id TEXT PRIMARY KEY` - - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` - - `aggregate_id TEXT NOT NULL` - - `accepted_at TEXT NOT NULL` - - `result_sequence INTEGER NOT NULL` - - `status TEXT NOT NULL CHECK IN ('accepted','rejected')` - - `error TEXT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: `apps/server/src/orchestration` dispatch boundary + `apps/server/src/persistence` -- Status: `to-replace` -- Notes: missing table and missing idempotency flow. - -### W3 - -- Spec ref: `7.1.3 checkpoint_diff_blobs` -- Requirement: store large plaintext diffs separate from checkpoint summaries. -- SQL contract: - - `thread_id TEXT NOT NULL` - - `from_turn_count INTEGER NOT NULL` - - `to_turn_count INTEGER NOT NULL` - - `diff TEXT NOT NULL` - - `created_at TEXT NOT NULL` - - `UNIQUE(thread_id, from_turn_count, to_turn_count)` -- Current writer path: none -- Current reader path: none -- Owner module: `apps/server/src/persistence` + turn diff query service -- Status: `to-replace` -- Notes: no canonical diff blob table yet. - -### W4 - -- Spec ref: `7.1.4 provider_session_runtime` -- Requirement: server-internal provider runtime/resume state. -- SQL contract: - - `provider_session_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `provider_name TEXT NOT NULL` - - `adapter_key TEXT NOT NULL` - - `provider_thread_id TEXT NULL` - - `status TEXT NOT NULL CHECK IN ('starting','running','stopped','error')` - - `last_seen_at TEXT NOT NULL` - - `resume_cursor_json TEXT NULL` - - `runtime_payload_json TEXT NULL` -- Current writer path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) -- Current reader path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) -- Owner module: provider runtime manager + persistence runtime repository -- Status: `to-replace` -- Notes: existing `provider_sessions` schema is incompatible and too small. - -## 7.2 Canonical Persisted Event Schema - -### E1 - -- Spec ref: `7.2 OrchestrationPersistedEventSchema` -- Requirement: full typed persisted event envelope in shared contracts. -- SQL contract: envelope fields in W1 must map 1:1 to contracts schema. -- Current writer path: contracts defined in `packages/contracts/src/orchestration.ts` -- Current reader path: used by persistence decode boundaries (partial) -- Owner module: `packages/contracts` -- Status: `implemented` -- Notes: contract schema exists; DB + store mapping still incomplete. - -### E2 - -- Spec ref: `7.2 Rules/payload discriminated by eventType` -- Requirement: `payload` validation keyed by `eventType`. -- SQL contract: `event_type` drives payload decode schema; invalid combinations rejected. -- Current writer path: `packages/contracts/src/orchestration.ts` -- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` decode path -- Owner module: contracts + event store -- Status: `to-replace` -- Notes: decode is present but DB does not persist full envelope columns. - -### E3 - -- Spec ref: `7.2 Rules/provider ids scope` -- Requirement: provider ids live in metadata/provider payload, not as thread identity replacement. -- SQL contract: provider fields persisted inside `metadata_json`; `stream_id` remains project/thread id. -- Current writer path: `apps/server/src/orchestration/decider.ts` (metadata mostly empty) -- Current reader path: projector/event consumers -- Owner module: decider + provider ingestion + event store -- Status: `to-replace` -- Notes: metadata plumbing is incomplete in persistence path. - -### E4 - -- Spec ref: `7.2 Rules/streamVersion concurrency guard` -- Requirement: stream version monotonic per aggregate stream; enforced on write. -- SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream. -- Current writer path: none -- Current reader path: none -- Owner module: event store append logic + DB constraints -- Status: `to-replace` -- Notes: no stream version assignment/checking today. - -## 7.3 Required Projected Tables (Read Models) - -### P1 - -- Spec ref: `7.3.1 projection_projects` -- Requirement: persisted project projection table. -- SQL contract: - - `project_id TEXT PRIMARY KEY` - - `title TEXT NOT NULL` - - `workspace_root TEXT NOT NULL` - - `default_model TEXT NULL` - - `created_at TEXT NOT NULL` - - `updated_at TEXT NOT NULL` - - `deleted_at TEXT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: legacy `projects` table is separate concept and should be removed from orchestration model. - -### P2 - -- Spec ref: `7.3.2 projection_threads` -- Requirement: persisted thread projection table. -- SQL contract: - - `thread_id TEXT PRIMARY KEY` - - `project_id TEXT NOT NULL` - - `title TEXT NOT NULL` - - `model TEXT NOT NULL` - - `branch TEXT NULL` - - `worktree_path TEXT NULL` - - `latest_turn_id TEXT NULL` - - `created_at TEXT NOT NULL` - - `updated_at TEXT NOT NULL` - - `deleted_at TEXT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: missing table and projector writes. - -### P3 - -- Spec ref: `7.3.3 projection_thread_messages` -- Requirement: persisted thread message projection table. -- SQL contract: - - `message_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NULL` - - `role TEXT NOT NULL CHECK IN ('user','assistant','system')` - - `text TEXT NOT NULL` - - `is_streaming INTEGER/BOOLEAN NOT NULL` - - `created_at TEXT NOT NULL` - - `updated_at TEXT NOT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: missing table and message projection writes. - -### P4 - -- Spec ref: `7.3.4 projection_thread_activities` -- Requirement: persisted thread activity projection table. -- SQL contract: - - `activity_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NULL` - - `tone TEXT NOT NULL CHECK IN ('info','tool','approval','error')` - - `kind TEXT NOT NULL` - - `summary TEXT NOT NULL` - - `payload_json TEXT NOT NULL` - - `created_at TEXT NOT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: no canonical activity projection persistence. - -### P5 - -- Spec ref: `7.3.5 projection_thread_sessions` -- Requirement: persisted thread session projection table. -- SQL contract: - - `thread_id TEXT PRIMARY KEY` - - `status TEXT NOT NULL CHECK IN ('idle','starting','running','ready','interrupted','stopped','error')` - - `provider_name TEXT NULL` - - `provider_session_id TEXT NULL` - - `provider_thread_id TEXT NULL` - - `active_turn_id TEXT NULL` - - `last_error TEXT NULL` - - `updated_at TEXT NOT NULL` -- Current writer path: none (in-memory projector only) -- Current reader path: none (snapshot not DB-projected) -- Owner module: projector pipeline + snapshot query -- Status: `to-replace` -- Notes: current provider session table is not this domain projection. - -### P6 - -- Spec ref: `7.3.6 projection_thread_turns` -- Requirement: persisted thread turn projection table. -- SQL contract: - - `turn_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_count INTEGER NOT NULL` - - `status TEXT NOT NULL CHECK IN ('running','completed','interrupted','error')` - - `user_message_id TEXT NULL` - - `assistant_message_id TEXT NULL` - - `started_at TEXT NOT NULL` - - `completed_at TEXT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: projector pipeline + session/turn query helpers -- Status: `to-replace` -- Notes: missing table and projection logic. - -### P7 - -- Spec ref: `7.3.7 projection_checkpoints` -- Requirement: persisted checkpoint summary projection table. -- SQL contract: - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NOT NULL` - - `checkpoint_turn_count INTEGER NOT NULL` - - `checkpoint_ref TEXT NOT NULL` - - `status TEXT NOT NULL CHECK IN ('ready','missing','error')` - - `files_json TEXT NOT NULL` - - `assistant_message_id TEXT NULL` - - `completed_at TEXT NOT NULL` - - `UNIQUE(thread_id, checkpoint_turn_count)` -- Current writer path: legacy `provider_checkpoints` writes in `apps/server/src/persistence/Layers/Checkpoints.ts` -- Current reader path: legacy checkpoint repository -- Owner module: projector pipeline + checkpoint query layer -- Status: `to-replace` -- Notes: current table semantics do not match canonical checkpoint projection schema. - -### P8 - -- Spec ref: `7.3.8 projection_pending_approvals` -- Requirement: persisted pending-approval projection table. -- SQL contract: - - `request_id TEXT PRIMARY KEY` - - `thread_id TEXT NOT NULL` - - `turn_id TEXT NULL` - - `status TEXT NOT NULL CHECK IN ('pending','resolved')` - - `decision TEXT NULL CHECK IN ('accept','acceptForSession','decline','cancel')` - - `created_at TEXT NOT NULL` - - `resolved_at TEXT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: projector pipeline + approval query layer -- Status: `to-replace` -- Notes: missing table and projection logic. - -### P9 - -- Spec ref: `7.3.9 projection_state` -- Requirement: projector progress tracking table. -- SQL contract: - - `projector TEXT PRIMARY KEY` - - `last_applied_sequence INTEGER NOT NULL` - - `updated_at TEXT NOT NULL` -- Current writer path: none -- Current reader path: none -- Owner module: projector runner/checkpointing -- Status: `to-replace` -- Notes: missing table and projector bookkeeping. - -### P10 - -- Spec ref: `7.3 Projection consistency rules` -- Requirement: projector row updates and `projection_state` update must be atomic per event. -- SQL contract: per-projector transaction boundary covering both projection write and state update. -- Current writer path: none (in-memory projector has no SQL transaction) -- Current reader path: none -- Owner module: projector runner -- Status: `to-replace` -- Notes: requires transactional projection executor. - -### P11 - -- Spec ref: `7.3 Optional debug field` -- Requirement: `lastEventSequence` on projection rows is optional and not required for correctness. -- SQL contract: optional; not required in baseline schema. -- Current writer path: none -- Current reader path: none -- Owner module: projector runner -- Status: `required` -- Notes: interpretation: exclude from first cutover unless debugging requires it. - -## 7.4 Snapshot and RPC Requirements - -### R1 - -- Spec ref: `7.4.1` -- Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`. -- SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`. -- Current writer path: in-memory model built in `apps/server/src/orchestration/projector.ts` -- Current reader path: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts#getReadModel` -- Owner module: snapshot query service + ws RPC handler -- Status: `delete` -- Notes: current in-memory read model path must be removed for SPEC compliance. - -### R2 - -- Spec ref: `7.4.2` -- Requirement: snapshot `projects[]` source is `projection_projects`. -- SQL contract: `projects` collection assembled from `projection_projects` rows. -- Current writer path: none -- Current reader path: in-memory thread/project arrays -- Owner module: snapshot query service -- Status: `to-replace` -- Notes: no DB project projection reader exists yet. - -### R3 - -- Spec ref: `7.4.3` -- Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields. -- SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`. -- Current writer path: legacy checkpoint repo data model -- Current reader path: in-memory checkpoints from orchestration events -- Owner module: snapshot query service + checkpoint projector -- Status: `to-replace` -- Notes: canonical projection table and reader not implemented. - -### R4 - -- Spec ref: `7.4.4` -- Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs. -- SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only. -- Current writer path: none for diff blobs -- Current reader path: `orchestration.getTurnDiff` schema exists, data backing incomplete -- Owner module: ws RPC handler + diff query service -- Status: `to-replace` -- Notes: current checkpoint repository is not canonical source. - -### R5 - -- Spec ref: `7.4.5` -- Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`. -- SQL contract: session lookup by `thread_id` from projection table. -- Current writer path: mixed provider/session handling paths -- Current reader path: legacy provider session persistence lookups -- Owner module: provider dispatch/session resolution -- Status: `to-replace` -- Notes: remove provider-session-as-routing-key behavior. - -### R6 - -- Spec ref: `7.4.6` -- Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors. -- SQL contract: `MIN(last_applied_sequence)` across required projector keys. -- Current writer path: none -- Current reader path: currently from in-memory event projection sequence -- Owner module: snapshot query service -- Status: `to-replace` -- Notes: must move from in-memory sequence to DB projection-state semantics. - -### R7 - -- Spec ref: `7.4.7` -- Requirement: snapshot/replay handoff has no gap (`getSnapshot` -> subscribe from snapshot sequence). -- SQL contract: read consistency strategy guaranteeing no missing events between snapshot visibility and replay start. -- Current writer path: event stream via `OrchestrationEventStore.readFromSequence` -- Current reader path: ws replay flow in `apps/server/src/wsServer.ts` -- Owner module: ws RPC + event stream handoff layer -- Status: `to-replace` -- Notes: interpretation requires explicit consistency boundary (transaction, sequence fence, or equivalent). - -## Ambiguous/Interpretation Decisions (tracked upfront) - -### A1 - -- Topic: `orchestration_events.stream_id` vs event runtime `aggregateId` naming. -- Decision: persist canonical DB column name `stream_id`; map to runtime `aggregateId` where needed in decider/projector code. - -### A2 - -- Topic: JSON column typing in SQLite for `payload`, `metadata`, projection payload/files, runtime cursor/payload. -- Decision: store as `TEXT` JSON with strict encode/decode schemas at boundaries. - -### A3 - -- Topic: `snapshotSequence` dependency set for min-sequence computation. -- Decision: include all projectors used to construct snapshot payload (`projects`, `threads`, `messages`, `activities`, `sessions`, `turns`, `checkpoints`, `pending_approvals`). - -### A4 - -- Topic: no-gap handoff mechanism in `7.4.7`. -- Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`. - -## Checklist Completeness Statement - -- Coverage scope: `SPEC.md` sections `7.1`, `7.2`, `7.3`, `7.4`. -- Requirement rows present: `W1-W4`, `E1-E4`, `P1-P11`, `R1-R7`. -- Unclassified rows: `0`. diff --git a/.plans/t3-connect-remote-setup.html b/.plans/t3-connect-remote-setup.html deleted file mode 100644 index 101c293bee1f..000000000000 --- a/.plans/t3-connect-remote-setup.html +++ /dev/null @@ -1,257 +0,0 @@ - - - - - -Plan: seamless `npx t3 connect` for remote boxes - - - -
- -

Seamless npx t3 connect for remote boxes

-

Design principle: the smallest diff that ships the UX. No relay/infra changes, no new backend surface, no new auth primitives — every step reuses code that already exists. One PR, built as four phases with clear commit boundaries — each phase compiles, passes tests, and leaves the product working, so the PR reviews commit-by-commit. (Phase 4, web-triggered update, is an optional follow-up PR.)

- -
-$ npx t3 connect

-To set up T3 Connect, open this URL and sign in:
-  https://app.t3.codes/connect#B64URL_STATE_AND_CHALLENGE

-Enter your authentication code: [code]

-Connected as theo@t3.gg!

-Run T3 Code in the background whenever this machine boots? (y/n): y

-T3 Code is set up and ready to go. -
- -

Why this is a small change

-

The entire t3 connect data plane already works: Clerk PKCE token exchange, encrypted secret store, cloudflared relay-client install, relay environment linking, DPoP tokens. The only broken piece on an SSH box is the redirect: CliTokenManager.login() hardcodes a loopback callback (http://127.0.0.1:34338/callback) that requires a browser on the same machine.

-

We swap that one leg for a hosted out-of-band authorization page and keep everything else. Because PKCE's code_verifier never leaves the box, the displayed one-time code is useless to anyone who sees it — no new token-minting or storage is needed anywhere.

- -
-

Reused as-is (zero changes)

-
    -
  • exchangeToken() PKCE exchange — apps/server/src/cloud/CliTokenManager.ts:147
  • -
  • Token persistence in ServerSecretStore (cloud-cli-oauth-token)
  • -
  • acquireRelayClientForLink() cloudflared install + progress — cli/connect.ts:146
  • -
  • CliState.setCliDesiredCloudLink() + server-side provisioning on start
  • -
  • All relay endpoints (infra/relay) and contracts — untouched
  • -
  • Existing subcommands login/link/status/unlink/logout — semantics unchanged
  • -
  • Web app Clerk session + hosted-page precedent (routes/pair.tsx, hostedPairing.ts)
  • -
-
- -

Auth flow (hosted out-of-band OAuth, Clerk PKCE)

- -
- - - - - - Remote box — t3 CLI - Laptop — app.t3.codes - Clerk - - - - - - - 1. gen verifier + challenge + state - - - - - 2. user opens /connect#{state,challenge} - - - - - 3. sign in → /oauth/authorize (PKCE) - - - - - 4. redirect /connect/callback?code&state - - - - 5. shows account + authorization code - - - - - 6. user enters code in terminal - - - - - 7. POST /oauth/token {code + verifier} → access/refresh tokens - - - - 8. store token, set desired link, - install relay client → Connected! - -
The verifier never leaves the box (steps 1→7), so the authorization code is worthless if observed. state/challenge ride the URL fragment — they are not secrets.
-
- -
-

Details that keep it simple

-
    -
  • Stateless URL, no short-link service. The /connect page reads state + code_challenge from the URL fragment and builds the Clerk authorize URL client-side. ~100-char URL — fine to transfer into an SSH session.
  • -
  • State check without a backend: the callback page displays one authorization blob of code.state; the CLI splits it and verifies state matches what it generated. One line on each side, preserves the loopback flow's CSRF check.
  • -
  • Phishing is addressed with copy, not code: the callback page shows which account is being connected ("Connecting as theo@…") and warns: "Only enter this code in a terminal session you started yourself." No mechanism needed.
  • -
  • Code expiry is a non-issue: Clerk auth codes live 10 minutes — the same timeout the existing loopback flow already uses. Wrong/expired code → friendly retry that reprints the URL.
  • -
  • One external config step: register https://app.t3.codes/connect/callback as an allowed redirect URI on the existing Clerk CLI OAuth client. No new client, no new scopes.
  • -
-
- -

The phases (one PR, one commit each)

-

Ordering is dependency order: each phase is independently revertable and the tree is green at every boundary. Phases 1–3 are the PR; phase 4 ships separately later.

- - - - - - - - - - - - - - - - - - - - - - - - - - - -
PhaseScopeFiles~LOC
1Hosted code page (web-only, purely additive, zero risk). Two static routes modeled on pair.tsx: /connect (ensure Clerk session, then client-side redirect to authorize — it's a static SPA, no server 302) and /connect/callback (validate params, show account + copyable code + safety warning). Both routes guard against non-hosted deployments — redirect to / unless isHostedStaticApp(), same pattern as pair.tsx, since this bundle also ships in local instances. Plus the Clerk dashboard redirect-URI entry.apps/web/src/routes/connect.tsx
apps/web/src/routes/connect.callback.tsx
~200
2CLI out-of-band OAuth flow + single command. Add an out-of-band OAuth login path to CliTokenManager (print URL, Prompt.text for the code, reuse exchangeToken). Make bare t3 connect a handler = login + link (subcommands untouched). Auto-pick headless mode inside SSH sessions (SSH_CONNECTION/SSH_TTY — nothing else); --headless flag as manual override. Loopback stays the default on desktop — no regression.cloud/CliTokenManager.ts (+60)
cloud/publicConfig.ts (+10)
cli/connect.ts (+60)
~150
3Background on boot — Linux first (the SSH case). One new module: pinned runtime install to ~/.t3/runtime/versions/<v> + current symlink, systemd user unit with absolute node/t3 paths, enable-linger. y/n prompt at the end of connect; teardown in logout. Install and service-start failures must land in a log file (under ~/.t3/userdata/logs/) whose path is printed at connect time — systemd user units fail invisibly otherwise. Unit-file generation is pure string-building → trivially testable. macOS launchd / Windows follow as 3b/3c only if wanted.cloud/bootService.ts (new)
cli/connect.ts (+prompt/teardown)
~250
4Web-triggered update (optional follow-up PR; not part of this one, not needed for the core UX). Web detects daemon version < latest-on-channel using the existing version-skew surface + hosted manifest; one authenticated "update" command — client says update, daemon resolves/verifies the version itself (never client-specified — that would be RCE). Stage install → verify → atomic symlink swap → systemctl --user restart. Progress streams reuse the RelayClientInstallProgressEvent pattern.web banner + one control command + daemon update routinelater
- -

Runtime layout (phase 3)

-
~/.t3/runtime/
-├── versions/0.0.27/        ← npm install --prefix (gets native deps right: node-pty etc.)
-└── current -> versions/0.0.27
-
-~/.config/systemd/user/t3code.service   ← ExecStart=/abs/path/node .../current/.../t3 serve
-loginctl enable-linger $USER            ← survives SSH logout / reboot
-

Why a real npm install and not "reuse the npx binary": the npx cache is ephemeral and t3 ships native deps (node-pty, @ff-labs/fff-node) that need per-platform prebuilds. Why pinned and not npx t3@latest in the unit: a boot-time registry fetch means the box may simply not come up (network down, PATH-less systemd env, nvm). Deterministic boot; updates happen out-of-band (phase 4 follow-up) or by re-running npx t3 connect.

- -

Explicitly not doing

-
    -
  • Relay / infra / contracts changes — none, in any phase
  • -
  • Short-link service (app.t3.codes/c/AB7K) — only matters for hand-typing; revisit if ever needed
  • -
  • RFC 8628 device grant — wrong UX direction, unverified Clerk support
  • -
  • Auto-update loop in the daemon — web-triggered only (phase 4 follow-up), user stays in control
  • -
  • Project auto-registration — workspace assumed set up; the web UI handles the rest
  • -
  • Changing existing loopback flow, subcommands, or desktop behavior
  • -
- -

Risks & checks

-
    -
  • Clerk redirect URI: confirm the CLI OAuth client accepts the hosted redirect and that the token endpoint honors PKCE exchange for codes issued to it. Verify in staging before the phase 2 commit. (Only external dependency in the plan.)
  • -
  • systemd user env is minimal: always write absolute paths for node + t3 into the unit; never rely on PATH. Service failures are invisible by default — hence the phase 3 requirement to log to a printed file path.
  • -
  • Linger prompt honesty: the y/n prompt should say the machine becomes reachable via T3 Connect whenever powered on — that's the feature, but say it.
  • -
  • Re-running connect when linked → idempotent: refresh token, re-confirm service, done.
  • -
- -

Decision log

-
    -
  • Auth: hosted out-of-band OAuth redirect on Clerk PKCE (not relay-brokered pairing, not device grant) — chosen for minimal new surface.
  • -
  • URL: stateless static page, no backend short-link.
  • -
  • Service: real per-user login service (systemd user + linger first); detect + offer install, never silent.
  • -
  • Binary: pinned managed runtime under ~/.t3; interactive npx usage untouched.
  • -
  • Updates: not always-latest; web UI surfaces available updates with one-click trigger (phase 4 follow-up).
  • -
  • Delivery: one PR with a commit per phase (green tree at every boundary), not separate PRs.
  • -
  • Workspace: assumed already set up; no auto-registration.
  • -
- -
- - diff --git a/AGENTS.md b/AGENTS.md index 26f66c32234a..8a1998bf4e57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ You can think of T3 Code as an open source "bring-your-own-subscription" alterna ## What makes T3 Code special? -We have over 100,000 users who love T3 Code. It's important we maintain the things they love as we continue to iterate on the product. Here's a brief list of the things we can never compromise on. +We have over 200,000 users who love T3 Code. It's important we maintain the things they love as we continue to iterate on the product. Here's a brief list of the things we can never compromise on. ### 1. Open at the core @@ -115,9 +115,17 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. +- Upload PR evidence to GitHub. Never commit PR-only screenshots or assets such as `.github/pr-assets/`. - One concern per PR. If the description says "also", split it. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. +## Plans and work artifacts + +- Do not commit implementation plans, research notes, or agent scratch files. Keep temporary working material outside the worktree. `.plans/` is gitignored only as a safety net for legacy tooling. +- Track active maintainer work in the GitHub issue or project item that owns it. External proposals follow `CONTRIBUTING.md` and belong in Ideas discussions. +- Put durable architecture, constraints, and decisions in `docs/internals/`. Update those docs when the product changes so agents find current facts instead of abandoned intentions. +- A merged PR is the implementation record. Close or update its tracking item when the work lands; do not preserve a second checklist in the repository. + ## How it works Clients send typed WebSocket requests. The server turns them into _commands_, a pure _decider_ turns commands into persisted _events_, and a _projector_ derives the read model the UI renders. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Side effects run in queue-backed _reactors_ that emit _receipts_ when milestones land. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a34a55f16acf..c88a69239577 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.33", + "version": "0.0.34", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index c41bb34bb433..3acaf7154508 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -53,6 +53,34 @@ describe("ElectronDialog", () => { }).pipe(Effect.provide(ElectronDialog.layer)), ); + it.effect("opens a single-file picker when multiple selections are disabled", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/pictures/icon.png"], + }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const paths = yield* dialog.pickFiles({ + owner: Option.none(), + defaultPath: Option.some("/project"), + filters: [{ name: "Images", extensions: ["png"] }], + multiple: false, + }); + + assert.deepEqual(paths, ["/pictures/icon.png"]); + assert.deepEqual(showOpenDialogMock.mock.calls, [ + [ + { + defaultPath: "/project", + filters: [{ name: "Images", extensions: ["png"] }], + properties: ["openFile"], + }, + ], + ]); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + it.effect("preserves message box request context and cause", () => Effect.gen(function* () { const cause = new Error("message box failed"); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index c33a24befcf8..4300d9ab0d39 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -84,6 +84,7 @@ export interface ElectronDialogPickFilesInput { readonly owner: Option.Option; readonly defaultPath: Option.Option; readonly filters: readonly Electron.FileFilter[]; + readonly multiple: boolean; } export class ElectronDialog extends Context.Service< @@ -144,7 +145,7 @@ export const make = ElectronDialog.of({ }); const defaultPath = Option.getOrNull(input.defaultPath); const openDialogOptions: Electron.OpenDialogOptions = { - properties: ["openFile", "multiSelections"], + properties: input.multiple ? ["openFile", "multiSelections"] : ["openFile"], filters: [...input.filters], ...(defaultPath === null ? {} : { defaultPath }), }; diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index f5c85769cee5..9ae6f502b000 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -36,6 +36,41 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens remote SSH editor URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal( + "vscode://vscode-remote/ssh-remote+example.com/home/user/project", + ); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["vscode://vscode-remote/ssh-remote+example.com/home/user/project"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open remote editor URLs with userinfo", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal( + "vscode://user@vscode-remote/ssh-remote+example.com/home/user/project", + ), + electronShell.openExternal( + "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", + ), + ]); + + assert.deepEqual(results, [false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open unsafe external URLs", () => Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; @@ -46,6 +81,20 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("does not open non-remote editor URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal( + "vscode://ms-python.python/some-command?argument=attacker", + ); + + assert.equal(result, false); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("returns false when Electron rejects openExternal", () => Effect.gen(function* () { openExternalMock.mockRejectedValue(new Error("open failed")); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 126be71b6d4f..2ed13bfebd0f 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -8,14 +8,21 @@ import * as Electron from "electron"; // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) // must reach the OS handler; every other non-web scheme stays blocked. -const SAFE_EXTERNAL_PROTOCOLS = new Set([ - "http:", - "https:", - ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { +const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); +const REMOTE_EDITOR_PROTOCOLS = new Set( + REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { const scheme = remoteSchemeForEditor(id); return scheme === undefined ? [] : [`${scheme}:`]; }), -]); +); + +const isRemoteEditorUrl = (url: URL) => + REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && + url.username.length === 0 && + url.password.length === 0 && + url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length; export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { @@ -24,7 +31,9 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { try { const url = new URL(rawUrl); - return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none(); + return SAFE_WEB_PROTOCOLS.has(url.protocol) || isRemoteEditorUrl(url) + ? Option.some(url.href) + : Option.none(); } catch { return Option.none(); } diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 37fd873a1b03..8e8317db7971 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -39,6 +39,7 @@ import { openExternal, probeRemoteEditors, pickFolder, + pickProjectFavicon, pickThemeFiles, setTheme, showContextMenu, @@ -82,6 +83,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslOnly); yield* ipc.handle(pickFolder); + yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 180e02810801..c4ef82ec8cb7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d39563..203151c2660e 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -2,13 +2,19 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { vi } from "vite-plus/test"; import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; +import { + getLocalEnvironmentBootstraps, + getWindowFullscreenState, + pickProjectFavicon, +} from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -146,3 +152,38 @@ describe("getWindowFullscreenState", () => { ); }); }); + +describe("pickProjectFavicon", () => { + it.effect("opens a single-image picker from the project directory", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + ), + ), + ); + + assert.strictEqual(result, "/pictures/icon.png"); + assert.deepEqual(pickFiles.mock.calls, [ + [ + { + owner: Option.none(), + defaultPath: Option.some("/project"), + multiple: false, + filters: [ + { + name: "Images", + extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"], + }, + ], + }, + ], + ]); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 0c7e90b95072..edae8394302c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -12,6 +12,7 @@ import { type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; +import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; import { isCommandAvailable } from "@t3tools/shared/shell"; import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; @@ -234,6 +235,28 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ }), }); +export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, + payload: Schema.UndefinedOr(Schema.String), + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const paths = yield* dialog.pickFiles({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: Option.fromNullishOr(initialPath), + multiple: false, + filters: [ + { + name: "Images", + extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)), + }, + ], + }); + return paths[0] ?? null; + }), +}); + export const setTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_THEME_CHANNEL, payload: DesktopThemeSchema, @@ -323,6 +346,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ owner: yield* electronWindow.focusedMainOrFirst, defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(), filters: [{ name: "JSON", extensions: ["json"] }], + multiple: true, }); if (paths.length === 0) { return null; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ee03141f2d82..407c7c3ef498 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -101,6 +101,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + pickProjectFavicon: (initialPath) => + ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index dd7e23e5d9f1..3e7d19341fd3 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, browserDefaultAppearance: "dark", @@ -36,6 +37,7 @@ const clientSettings: ClientSettings = { fontSmoothing: true, glassOpacity: 80, planModeEnabled: false, + showSkillsInSlashMenu: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarAutoSettleOnMerge: true, diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 9d6bbaea6bcb..78ea56e75131 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -58,9 +58,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - expect(() => - normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), - ).not.toThrow(); const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); }); diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index d2a3c774b8a6..fe11ddd4c069 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -1,6 +1,9 @@ import type { VercelConfig } from "@vercel/config/v1"; export const config: VercelConfig = { + git: { + deploymentEnabled: false, + }, installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 5fbe6d4dff44..b0934e873a7b 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; import type { MarkdownNode } from "react-native-nitro-markdown/headless"; @@ -9,10 +9,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios import type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "./SelectableMarkdownText.types"; +/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ +export const MarkdownImageRendererContext = createContext(null); + type HighlightedCode = ReadonlyArray>; const highlightedCodeCache = new Map(); @@ -379,6 +383,7 @@ function NativeMarkdownImage(props: { readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { + const renderImage = useContext(MarkdownImageRendererContext); const href = props.node.href; if (!href) { return ( @@ -391,6 +396,17 @@ function NativeMarkdownImage(props: { ); } + if (renderImage) { + const rendered = renderImage({ + href, + alt: props.node.alt ?? null, + title: props.node.title ?? null, + }); + if (rendered != null) { + return <>{rendered}; + } + } + return ( = []; export type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, @@ -36,6 +38,7 @@ export function SelectableMarkdownText({ highlightCode, preserveSoftBreaks = false, onLinkPress, + renderImage, marginTop = 0, marginBottom = 0, }: SelectableMarkdownTextProps) { @@ -59,38 +62,40 @@ export function SelectableMarkdownText({ }, [markdown, preserveSoftBreaks, skills]); return ( - // A percentage width here creates a cyclic intrinsic measurement inside - // shrink-to-fit containers such as user-message bubbles. Yoga then gives - // the native text node an unbounded second pass and the parent only clips - // the resulting single-line width instead of reflowing it. - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); + + {/* A percentage width here creates a cyclic intrinsic measurement inside + shrink-to-fit containers such as user-message bubbles. Yoga then gives + the native text node an unbounded second pass and the parent only clips + the resulting single-line width instead of reflowing it. */} + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); - return ( - - {content} - - ); - })} - + return ( + + {content} + + ); + })} + + ); } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx index fcb2472f6488..006d33e7259d 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types export type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 42cc3cd6fb63..00260b0c4f27 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -36,6 +36,20 @@ export interface SelectableMarkdownSkill { readonly displayName?: string | null; } +export interface MarkdownImageRequest { + readonly href: string; + readonly alt: string | null; + readonly title: string | null; +} + +/** + * App-supplied renderer for markdown images. The module cannot load + * workspace-relative image paths itself — the host app resolves them (for + * example through a signed asset URL) and returns the element to show. + * Returning null falls back to the module's plain remote-URI rendering. + */ +export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; + export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; @@ -43,6 +57,7 @@ export interface SelectableMarkdownTextProps { readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; + readonly renderImage?: MarkdownImageRenderer; readonly marginTop?: number; readonly marginBottom?: number; } diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index f13891e3ff80..20637c6ba0f4 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -3,8 +3,10 @@ import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const RELATIVE_FILE_PATH_PATTERN = + /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = + /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSIX_FILE_ROOT_PREFIXES = [ "/Users/", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index de53a37c995b..1097f6a33762 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -81,6 +81,7 @@ "expo-constants": "~56.0.18", "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.20", + "expo-device": "~56.0.4", "expo-file-system": "~56.0.8", "expo-font": "~56.0.7", "expo-glass-effect": "~56.0.4", diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 74308467a052..32f915e7af5c 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -2,6 +2,7 @@ import { IconAdjustmentsHorizontal, IconAlertCircle, IconAlertTriangle, + IconApps, IconArchive, IconArrowBackUp, IconArrowDownCircle, @@ -13,6 +14,7 @@ import { IconArrowsMaximize, IconBellRinging, IconBolt, + IconBox, IconCamera, IconChartBar, IconCheck, @@ -104,6 +106,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, + cube: IconBox, "chevron.down": IconChevronDown, "chevron.left": IconChevronLeft, "chevron.left.forwardslash.chevron.right": IconCode, @@ -141,6 +144,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "sidebar.right": IconLayoutSidebarRight, "slider.horizontal.3": IconAdjustmentsHorizontal, "square.and.pencil": IconEdit, + "square.grid.2x2": IconApps, "square.split.2x1": IconLayoutColumns, "sun.max": IconSun, "stop.fill": IconPlayerStopFilled, diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 28f7cfe57a7f..bfba418c9fce 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -33,6 +33,7 @@ export function brandTitleOffset(nativeLeadingItem: boolean): number { */ export function CompactBrandTitle( props: { + readonly allowFontScaling?: boolean; readonly nativeLeadingItem?: boolean; } = {}, ) { @@ -57,6 +58,7 @@ export function CompactBrandTitle( > ; + return ; } export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 852535d9d10b..8e699e4c24fd 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import Constants from "expo-constants"; import * as Network from "expo-network"; import { AppState } from "react-native"; @@ -166,7 +167,7 @@ const capabilitiesLayer = Layer.effectContext( Context.add( ClientPresentation, ClientPresentation.of({ - metadata: authClientMetadata(), + metadata: authClientMetadata(Constants.expoConfig?.version), scopes: AuthStandardClientScopes, }), ), diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index c75d60d5fdf8..4d7b0864184b 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -33,6 +33,11 @@ vi.mock("expo-constants", () => ({ }, })); +vi.mock("expo-device", () => ({ + osVersion: "18.4.1", + modelName: "iPhone 15 Pro", +})); + vi.mock("react-native", () => ({ Platform: { OS: "ios", diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 75f78738ade5..6d07f40a52dd 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; -import { splitEnvironmentSections } from "./environmentSections"; +import { relayManagedEnvironmentIds, splitEnvironmentSections } from "./environmentSections"; function connectedEnvironment( input: Omit, "environmentId"> & { @@ -34,6 +34,17 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { }; } +describe("relayManagedEnvironmentIds", () => { + it("leaves out a backend that was saved directly", () => { + const ids = relayManagedEnvironmentIds([ + connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false }), + connectedEnvironment({ environmentId: "environment-cloud", isRelayManaged: true }), + ]); + + expect([...ids]).toEqual([EnvironmentId.make("environment-cloud")]); + }); +}); + describe("mobile environment settings sections", () => { it("keeps saved relay-managed connections under T3 Connect", () => { const local = connectedEnvironment({ @@ -111,6 +122,24 @@ describe("mobile environment settings sections", () => { expect(sections.availableCloudEnvironments).toEqual([]); }); + it("still offers a cloud environment saved directly as a local backend", () => { + const local = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: false, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [local], + cloudEnvironments: [cloudEnvironment("environment-cloud")], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect(sections.connectedCloudEnvironments).toEqual([]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-cloud")]); + }); + it("keeps failed relay environments in the local connection row", () => { const cloud = connectedEnvironment({ environmentId: "environment-cloud", diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts index fc6db479c2ff..10ba636dc576 100644 --- a/apps/mobile/src/features/connection/environmentSections.ts +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; @@ -12,10 +13,25 @@ export interface EnvironmentSections { readonly availableCloudEnvironments: ReadonlyArray; } -export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { - const savedEnvironmentIds = new Set( - input.connectedEnvironments.map((environment) => environment.environmentId), +/** + * Ids of the environments that already occupy a T3 Connect slot. A backend saved directly is + * not one of them, so it must not suppress the cloud environment that happens to share its id. + */ +export function relayManagedEnvironmentIds( + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly isRelayManaged: boolean; + }>, +): ReadonlySet { + return new Set( + environments + .filter((environment) => environment.isRelayManaged) + .map((environment) => environment.environmentId), ); +} + +export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { + const savedEnvironmentIds = relayManagedEnvironmentIds(input.connectedEnvironments); return { localEnvironments: input.connectedEnvironments.filter( diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index bad6b6f17209..faa34477569d 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -20,6 +20,7 @@ import { useEnvironments } from "../../state/environments"; import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; +import { relayManagedEnvironmentIds } from "./environmentSections"; export interface RelayEnvironmentView { readonly environment: RelayClientEnvironmentRecord; @@ -49,7 +50,7 @@ export function useConnectionController() { [environments], ); const registeredIds = useMemo( - () => new Set(connectedEnvironments.map((environment) => environment.environmentId)), + () => relayManagedEnvironmentIds(connectedEnvironments), [connectedEnvironments], ); const relayEnvironments = useMemo>( diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts index 85383514cb56..7345a7f366c5 100644 --- a/apps/mobile/src/features/files/fileTree.test.ts +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -68,7 +68,7 @@ describe("mobile file tree helpers", () => { const tree = buildFileTree([ { kind: "file", - path: ".plans/19-version-control-phase-1-vcs-driver-foundation.md", + path: "docs/internals/workspace-layout.md", }, { kind: "file", diff --git a/apps/mobile/src/features/home/AndroidHomeFab.tsx b/apps/mobile/src/features/home/AndroidHomeFab.tsx index 5c7d5a0b988c..c57964fce4a3 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.tsx @@ -6,8 +6,8 @@ import { SymbolView } from "../../components/AppSymbol"; import { useThemeColor } from "../../lib/useThemeColor"; /** - * Android-only wrapper that overlays a bottom-right new-task FAB on the home - * screen. Other platforms render children unchanged. + * Android-only wrapper that overlays a bottom-right new-task FAB on a thread + * list. Other platforms render children unchanged. */ export function AndroidHomeFabLayout(props: { readonly onStartNewTask: () => void; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 8061b1d1e85b..beabf66d9ea9 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -2,6 +2,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; +import { Platform } from "react-native"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -106,7 +107,11 @@ export function HomeRouteScreen() { return ( <> [] }} + options={ + Platform.OS === "android" + ? { headerShown: false } + : { title: "", headerTitle: "", unstable_headerLeftItems: () => [] } + } /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title after the split branch blanks the detail - header. The brand slot doubles as the connection status surface: - while an environment reconnects, the lockup fades to a status label - in place (no layout shift in the list below). */} + {/* Restore the header after leaving split view; screen options are + shallow-merged. The brand slot also doubles as the connection + status surface while an environment reconnects. */} - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "SettingsEnvironments" }, - }), - })} + options={{ + ...getConnectionAwareBrandHeaderOptions({ + onOpenEnvironments: () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }), + }), + headerShown: true, + }} /> + ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => { + (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { setChangeRequestByKey((current) => { const existing = current.get(threadKey) ?? null; if ( (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) + (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && + (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) ) { return current; } @@ -572,10 +574,13 @@ export function HomeScreen(props: HomeScreenProps) { () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); - const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const { + loaded: shelfPreferencesLoaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } = useThreadListV2ShelfPreferences(); // now is quantized to the minute and ticks so the inactivity auto-settle // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". @@ -785,6 +790,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -794,6 +800,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -890,6 +897,7 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectThread, props.savedConnectionsById, serverConfigs, + shelfPreferencesLoaded, settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Items, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index e00433de0ed9..9ea8eb88d42a 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -45,6 +45,7 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; +import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; @@ -435,6 +436,10 @@ function AdaptiveWorkspaceLayoutContent( }); }, [navigation]); + const handleStartNewTask = useCallback(() => { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + }, [navigation]); + // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks — on iOS it renders inside an independent nav tree. const handleOpenEnvironmentSettings = useCallback(() => { @@ -528,18 +533,22 @@ function AdaptiveWorkspaceLayoutContent( pointerEvents={panes.primarySidebarVisible ? "auto" : "none"} style={sidebarAnimatedStyle} > - + + + + + ) : null} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index c7e6b534a796..b48c7a0bdd94 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -10,6 +10,8 @@ import { getCloneDestinationBrowsePath, getCloneDestinationPath, getCloneDirectoryName, + getDefaultCloneUrl, + normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, @@ -662,7 +664,7 @@ export function AddProjectRepositoryScreen(props: { setIsSubmitting(true); const provider = addProjectRemoteSourceProvider(source); if (!provider) { - const remoteUrl = repositoryInput.trim(); + const remoteUrl = normalizePastedCloneUrl(repositoryInput); navigation.dispatch( StackActions.push("AddProjectDestination", { environmentId: environment.environmentId, @@ -691,7 +693,7 @@ export function AddProjectRepositoryScreen(props: { StackActions.push("AddProjectDestination", { environmentId: environment.environmentId, source, - remoteUrl: repository.sshUrl, + remoteUrl: getDefaultCloneUrl(repository), repositoryTitle: repository.nameWithOwner, repositoryName: getCloneDirectoryName(repository.nameWithOwner), }), diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 0eea51719521..cbc47c99c2e3 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -1,9 +1,13 @@ -import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; +import { + resolveProviderSkillSourceKind, + type ProviderSkillSourceKind, +} from "@t3tools/client-runtime/providerSkills"; import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; -import { SymbolView } from "../../components/AppSymbol"; +import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; import { memo } from "react"; import { Pressable, ScrollView, View, type ViewStyle } from "react-native"; +import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { GlassSurface } from "../../components/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; @@ -61,13 +65,22 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st ); } -function itemIcon(item: ComposerCommandItem) { +const SKILL_SOURCE_SYMBOL_BY_KIND: Record = { + app: "square.grid.2x2", + repo: "folder", + project: "folder", + personal: "person.crop.circle", + system: "gearshape", + other: "cube", +}; + +function itemIcon(item: ComposerCommandItem): AppSymbolName | null { switch (item.type) { case "slash-command": case "provider-slash-command": - return "terminal" as const; + return "terminal"; case "skill": - return "cube" as const; + return SKILL_SOURCE_SYMBOL_BY_KIND[resolveProviderSkillSourceKind(item.skill)]; case "path": return null; } @@ -106,6 +119,7 @@ const CommandRow = memo(function CommandRow(props: { readonly item: ComposerCommandItem; readonly onPress: () => void; readonly isLast: boolean; + readonly isSlashSkill: boolean; }) { const iconName = itemIcon(props.item); const iconColor = useThemeColor("--color-icon-subtle"); @@ -131,7 +145,14 @@ const CommandRow = memo(function CommandRow(props: { ) : null} - {props.item.label} + {props.isSlashSkill && props.item.type === "skill" ? ( + <> + skill: + {props.item.skill.name} + + ) : ( + props.item.label + )} {props.item.description ? ( @@ -168,6 +189,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( item={item} onPress={() => props.onSelect(item)} isLast={index === props.items.length - 1} + isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"} /> ))} diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index 377ae82aba8b..fb9cc72d25d3 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -1,4 +1,8 @@ -import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts"; +import type { + ApprovalRequestId, + ProviderApprovalDecision, + ProviderApprovalOption, +} from "@t3tools/contracts"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; @@ -13,7 +17,14 @@ export interface PendingApprovalCardProps { ) => Promise; } +const DEFAULT_APPROVAL_OPTIONS = [ + { decision: "accept", label: "Allow once" }, + { decision: "acceptForSession", label: "Allow session" }, + { decision: "decline", label: "Decline" }, +] satisfies ReadonlyArray; + export function PendingApprovalCard(props: PendingApprovalCardProps) { + const options = props.approval.options ?? DEFAULT_APPROVAL_OPTIONS; // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed // behind this card, so a translucent surface bleeds messages through it. return ( @@ -22,7 +33,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { Approval needed - {props.approval.requestKind} + {props.approval.appName ?? props.approval.requestKind} {props.approval.detail ? ( @@ -30,29 +41,32 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { ) : null} - void props.onRespond(props.approval.requestId, "accept")} - > - Allow once - - void props.onRespond(props.approval.requestId, "acceptForSession")} - > - - Allow session - - - void props.onRespond(props.approval.requestId, "decline")} - > - Decline - + {options.map((option) => ( + void props.onRespond(props.approval.requestId, option.decision)} + > + + {option.label} + + + ))} ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 087a96ea424f..c771aaebcb6e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -67,6 +67,7 @@ import { import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation, @@ -430,7 +431,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); } - return [...builtIn, ...providerCommands]; + const skillItems = (selectedProviderStatus?.skills ?? []) + .filter((skill) => matchesSlashSkillQuery(skill, q)) + .map((skill) => ({ + id: `skill:${skill.name}`, + type: "skill" as const, + skill, + label: `skill:${skill.name}`, + description: skill.shortDescription ?? skill.description ?? "", + })); + + return [...builtIn, ...providerCommands, ...skillItems]; } if (composerTrigger.kind === "skill") { @@ -541,7 +552,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { - await onSendMessage(); + const messageId = await onSendMessage(); + if (messageId === null) { + return; + } // Sending a prompt starts agent work: arm the lock-screen card while the // app is foregrounded and the activity token can be registered. Armed // after the send so its preference read and native Activity start don't diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e234838394ba..2c6860199722 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -79,6 +79,7 @@ import { } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; @@ -257,9 +258,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const selectedThreadKeyRef = useRef(selectedThreadKey); - const lastScrolledAnchorMessageIdRef = useRef(null); + const lastScrolledSubmittedMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); + const [submittedMessageId, setSubmittedMessageId] = useState(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Android keys the safe-area padding on keyboard visibility (#5988): the // back gesture closes the keyboard while the editor stays focused, and a @@ -458,17 +460,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { setAnchorMessageId(null); - lastScrolledAnchorMessageIdRef.current = null; + setSubmittedMessageId(null); + lastScrolledSubmittedMessageIdRef.current = null; setEndFollowEnabled(true); freeze.set(false); }, [freeze, selectedThreadKey]); useEffect(() => { if ( - anchorMessageId === null || - lastScrolledAnchorMessageIdRef.current === anchorMessageId || + submittedMessageId === null || + lastScrolledSubmittedMessageIdRef.current === submittedMessageId || contentPresentationKind !== "ready" || - !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) + !selectedThreadFeed.some( + (entry) => entry.type === "message" && entry.id === submittedMessageId, + ) ) { return; } @@ -478,7 +483,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (selectedThreadKeyRef.current !== targetThreadKey) { return; } - lastScrolledAnchorMessageIdRef.current = anchorMessageId; + lastScrolledSubmittedMessageIdRef.current = submittedMessageId; // Wait for the keyboard dismissal (started by blur() on send) to finish // before scrolling: scrollMessageToEnd freezes keyboard-driven inset // updates while it runs, and a close event swallowed by that freeze @@ -488,7 +493,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .then(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledAnchorMessageIdRef.current !== anchorMessageId + lastScrolledSubmittedMessageIdRef.current !== submittedMessageId ) { return; } @@ -497,17 +502,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .catch(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledAnchorMessageIdRef.current !== anchorMessageId + lastScrolledSubmittedMessageIdRef.current !== submittedMessageId ) { return; } - lastScrolledAnchorMessageIdRef.current = null; + lastScrolledSubmittedMessageIdRef.current = null; freeze.set(false); }); }); return () => cancelAnimationFrame(frame); }, [ - anchorMessageId, + submittedMessageId, freeze, contentPresentationKind, selectedThreadFeed, @@ -517,15 +522,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleSendMessage = useCallback(async () => { const targetThreadKey = selectedThreadKey; + const hasUserMessage = selectedThreadFeed.some( + (entry) => entry.type === "message" && entry.message.role === "user", + ); const messageId = await props.onSendMessage(); if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { return messageId; } - setAnchorMessageId(messageId); + setSubmittedMessageId(messageId); + setAnchorMessageId( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: anchorMessageId, + submittedMessageId: messageId, + hasStartedTurn: props.selectedThread.latestTurn !== null, + hasUserMessage, + queuedMessageCount: props.selectedThreadQueueCount, + }), + ); composerEditorRef.current?.blur(); return messageId; - }, [props.onSendMessage, selectedThreadKey]); + }, [ + anchorMessageId, + props.onSendMessage, + props.selectedThread.latestTurn, + props.selectedThreadQueueCount, + selectedThreadFeed, + selectedThreadKey, + ]); const collapseComposer = useCallback(() => { composerEditorRef.current?.blur(); @@ -595,6 +619,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} + submittedMessageId={submittedMessageId} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 54839f09a9bb..a627f4b0fb7e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2,6 +2,7 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import { materializePastedText, splitPastedTextSegments } from "@t3tools/shared/pastedText"; @@ -40,6 +41,7 @@ import { type ColorValue, useWindowDimensions, View, + type ViewStyle, } from "react-native"; import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; @@ -55,6 +57,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, + type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; @@ -74,7 +77,11 @@ import { } from "../review/nativeReviewDiffAdapter"; import { buildReviewParsedDiff } from "../review/reviewModel"; import { cn } from "../../lib/cn"; -import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout"; +import { + deriveCenteredContentHorizontalPadding, + deriveThreadFeedInitialContentInset, + type LayoutVariant, +} from "../../lib/layout"; import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, @@ -102,8 +109,9 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { useAssetUrl } from "../../state/assets"; +import { useAssetUrl, useAssetUrlState } from "../../state/assets"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; +import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", @@ -152,6 +160,7 @@ export interface ThreadFeedProps { readonly listRef: RefObject; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; + readonly submittedMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; @@ -194,6 +203,165 @@ function MessageAttachmentImage(props: { ); } +function ThreadMarkdownImageView(props: { + readonly uri: string | null; + readonly sourceKey: string; + readonly unavailable: boolean; + readonly alt: string | null; + readonly onPressImage: (uri: string) => void; +}) { + const codeBackground = useThemeColor("--color-md-code-bg"); + const [availableWidth, setAvailableWidth] = useState(0); + const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); + const [failedUri, setFailedUri] = useState(null); + + useEffect(() => { + setSourceSize(null); + }, [props.sourceKey]); + + useEffect(() => { + setFailedUri(null); + }, [props.uri]); + + const displaySize = + sourceSize === null + ? null + : resolveMarkdownImageDisplaySize({ + sourceWidth: sourceSize.width, + sourceHeight: sourceSize.height, + availableWidth, + }); + const failed = props.unavailable || (props.uri !== null && failedUri === props.uri); + const placeholderWidth: ViewStyle["width"] = + availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; + const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; + + return ( + setAvailableWidth(event.nativeEvent.layout.width)} + style={{ alignSelf: "stretch", gap: 6 }} + > + {props.uri === null || failed ? ( + + {failed ? ( + Image unavailable + ) : ( + + )} + + ) : ( + props.onPressImage(props.uri!)} + style={{ alignSelf: "flex-start" }} + > + + setFailedUri(props.uri)} + /> + + + )} + {props.alt ? ( + + {props.alt} + + ) : null} + + ); +} + +function ThreadMarkdownImageRequest(props: { + readonly uri: string; + readonly onLoad: (sourceSize: { width: number; height: number }) => void; + readonly onError: () => void; +}) { + const [loaded, setLoaded] = useState(false); + + return ( + <> + { + setLoaded(true); + props.onLoad(event.nativeEvent.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + {loaded ? null : ( + + Loading image… + + )} + + ); +} + +/** Markdown image whose src is a workspace file — loads through a signed asset URL. */ +function ThreadMarkdownImage(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly path: string; + readonly alt: string | null; + readonly onPressImage: (uri: string) => void; +}) { + const assetUrl = useAssetUrlState(props.environmentId, { + _tag: "workspace-file", + threadId: props.threadId, + path: props.path, + }); + + return ( + + ); +} + +function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { + return ( + undefined} + /> + ); +} + const MARKDOWN_MONO_FONT = Platform.select({ ios: "ui-monospace", android: "monospace", @@ -409,7 +577,10 @@ function useReviewCommentColors(): ReviewCommentColors { ); } -function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { +function useMarkdownStyles( + onLinkPress: (href: string) => void, + renderImage: MarkdownImageRenderer, +): MarkdownStyleSets { const { appearance, themeAppearance } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), @@ -614,6 +785,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe })} ), + image: ({ node }) => + node.href + ? (renderImage({ + href: node.href, + alt: node.alt ?? null, + title: node.title ?? null, + }) ?? undefined) + : undefined, code_inline: ({ content }) => { const value = content ?? ""; return ( @@ -787,6 +966,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe nativeMarkdownTypography, onLinkPress, regularFontFamily, + renderImage, themeMode, userBubbleForegroundMuted, userBubbleSkillForeground, @@ -806,6 +986,7 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; + readonly renderMarkdownImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -904,6 +1085,7 @@ function renderFeedEntry( reviewCommentColors={props.reviewCommentColors} skills={props.skills} onLinkPress={props.onMarkdownLinkPress} + renderImage={props.renderMarkdownImage} /> ) : null} {attachments.map((attachment) => { @@ -955,6 +1137,7 @@ function renderFeedEntry( skills={props.skills} textStyle={styles.nativeTextStyle} onLinkPress={props.onMarkdownLinkPress} + renderImage={props.renderMarkdownImage} /> ) : ( ; readonly onLinkPress: (href: string) => void; + readonly renderImage: MarkdownImageRenderer; }) { const pastedTextSegments = splitPastedTextSegments(props.text); if (pastedTextSegments.some((segment) => segment.type === "pasted-text")) { @@ -1079,6 +1263,7 @@ function UserMessageContent(props: { textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks onLinkPress={props.onLinkPress} + renderImage={props.renderImage} /> ); } @@ -1120,6 +1305,7 @@ function UserMessageContent(props: { textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks onLinkPress={props.onLinkPress} + renderImage={props.renderImage} /> ) : ( ( + (image) => { + const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); + if (imageSource._tag === "Direct") { + return ( + setExpandedImage({ uri })} + /> + ); + } + if (imageSource._tag === "Blocked") { + return ; + } + return ( + setExpandedImage({ uri })} + /> + ); + }, + [props.environmentId, props.threadId, props.workspaceRoot], + ); + const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); const reviewCommentColors = useReviewCommentColors(); // LegendList does not invalidate visible rows when only the renderItem closure changes. // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. @@ -1609,12 +1829,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { transitionEndFollow({ type: "reset" }); }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); useEffect(() => { - if (props.anchorMessageId !== null) { + if (props.submittedMessageId !== null) { clearUserScrollSettle(); userScrollSessionRef.current = false; transitionEndFollow({ type: "reset" }); } - }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); + }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]); const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); @@ -1646,10 +1866,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // The empty↔filled key below remounts the list, which resets its imperative // content-inset override — and useKeyboardChatComposerInset (mounted above // the remount boundary) deduplicates by height, so it never re-reports the - // composer inset to the fresh instance. Without this, the remounted list's - // initial scroll-to-end computes with a zero end inset and rests one - // composer-height short of the end. Layout effect: it must land before the - // list's first positioning tick or the one-shot initial scroll misses it. + // composer inset to the fresh instance. Re-report the measured overlay height + // (composer plus any pending approval / user-input card) so the remounted + // list's scroll math gets the true value; on Android the declarative + // contentInset floor below covers the window before this effect lands. const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; @@ -1663,7 +1883,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { resolveChatListAnchoredEndSpace( presentedFeed, props.anchorMessageId, - (entry) => (entry.type === "message" ? entry.id : null), + (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null), { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ), [presentedFeed, props.anchorMessageId, anchorTopInset], @@ -1867,6 +2087,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressImage, onMarkdownLinkPress, + renderMarkdownImage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1894,6 +2115,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkRow, props.environmentId, props.skills, + renderMarkdownImage, ], ); @@ -1954,6 +2176,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // ThreadDetailScreen); this tells LegendList's scroll math about the // extra so programmatic end scrolls land at the true resting offset. contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0} + // Android: the composer overlay only exists as the keyboard + // integration's animated bottom padding, which the list's scroll + // math cannot see until the inset reports above land — and those + // arrive via runOnJS, racing the remounted list's one-shot initial + // scroll-at-end. Seed the estimated overlay height as a declarative + // contentInset floor: LegendList consumes it in JS math only + // (Android's ScrollView has no native contentInset prop) and the + // first reported override REPLACES it instead of adding to it. + // Not on iOS: there the prop would reach UIKit and inset natively + // on top of the animated padding. + {...(initialContentInset ? { contentInset: initialContentInset } : {})} // The keyboard integration's offset math (end pinning, max scroll) // must add the same UIKit-added extra, or its keyboard-open end // targets land one safe-area short of the true resting offset. diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 2e8186fa8e25..0e74e27f8743 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,4 +1,3 @@ -import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -13,17 +12,16 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { SearchBarCommands } from "react-native-screens"; -import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; +import { CompactBrandTitle } from "../../components/CompactBrandTitle"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; @@ -34,12 +32,12 @@ import { useProjects, useThreadShells } from "../../state/entities"; import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; +import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { hasCustomHomeListOptions, PROJECT_SORT_OPTIONS, @@ -85,6 +83,7 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, + type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "./threadListV2"; @@ -96,48 +95,7 @@ type SidebarListItem = | ThreadListV2ListItem | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; -/** - * Shared capsule behind the sidebar header buttons — a native liquid-glass - * surface on iOS 26+, a tinted pill everywhere else. - */ -function SidebarHeaderButtonGroup(props: { - readonly children: ReactNode; - readonly colorScheme: "light" | "dark"; -}) { - const fallbackBackground = useThemeColor("--color-glass-surface"); - const fallbackBorder = useThemeColor("--color-header-border"); - if (isLiquidGlassSupported) { - return ( - - {props.children} - - ); - } - - return ( - - {props.children} - - ); -} - const SIDEBAR_STICKY_HEADER_HEIGHT = 106; -const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44; -const SIDEBAR_HEADER_WASH_OPACITY = { - dark: [0.22, 0.14, 0.04], - light: [0.46, 0.3, 0.08], -} as const; interface ThreadNavigationSidebarProps { readonly width: number; @@ -194,16 +152,13 @@ function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { const insets = useSafeAreaInsets(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); - const [headerIsOverContent, setHeaderIsOverContent] = useState(false); const searchInputRef = useRef(null); const searchBarRef = useRef(null); const openSwipeableRef = useRef(null); - const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, @@ -422,15 +377,16 @@ function ThreadNavigationSidebarPane( // PR states stream in per-row. The next partition applies the configured // merge rule and the always-on close rule. const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap + ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => { + (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { setChangeRequestByKey((current) => { const existing = current.get(threadKey) ?? null; if ( (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) + (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && + (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) ) { return current; } @@ -460,10 +416,13 @@ function ThreadNavigationSidebarPane( () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); - const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); - const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const { + loaded: shelfPreferencesLoaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } = useThreadListV2ShelfPreferences(); // now ticks per minute so the inactivity auto-settle boundary is actually // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". @@ -776,8 +735,6 @@ function ThreadNavigationSidebarPane( const borderColor = useThemeColor("--color-border"); const mutedColor = useThemeColor("--color-foreground-muted"); const placeholderColor = useThemeColor("--color-placeholder"); - const headerFadeColor = String(backgroundColor); - const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no @@ -806,19 +763,10 @@ function ThreadNavigationSidebarPane( }, [props.onSelectThread], ); - const handleScroll = useCallback((event: NativeSyntheticEvent) => { - const next = event.nativeEvent.contentOffset.y > 6; - if (headerIsOverContentRef.current === next) { - return; - } - headerIsOverContentRef.current = next; - setHeaderIsOverContent(next); - }, []); const handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); // Project shells load after the first rows draw, so the maps they feed have @@ -1008,6 +956,7 @@ function ThreadNavigationSidebarPane( return ( - - - - - - - - - - - - - {/* Title slot doubles as the connection status surface: while an - environment reconnects, "Threads" fades to a status label in + environment reconnects, the brand fades to a status label in place (no layout shift in the list below). */} - Threads - + + + } /> - + - + - - + + @@ -1416,12 +1339,6 @@ function ThreadNavigationSidebarPane( } const styles = StyleSheet.create({ - headerButtonGroup: { - alignItems: "center", - borderRadius: 22, - flexDirection: "row", - overflow: "hidden", - }, threadList: { flex: 1, }, diff --git a/apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts b/apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts new file mode 100644 index 000000000000..5ac64626a97e --- /dev/null +++ b/apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; + +const browserSkill = { + name: "browser", + path: "/skills/browser/SKILL.md", + enabled: true, + shortDescription: "Open and control the in-app browser", +}; + +describe("matchesSlashSkillQuery", () => { + it("matches the rendered skill prefix", () => { + expect(matchesSlashSkillQuery(browserSkill, "skill")).toBe(true); + expect(matchesSlashSkillQuery(browserSkill, "skill:brow")).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/composerSlashSkillSearch.ts b/apps/mobile/src/features/threads/composerSlashSkillSearch.ts new file mode 100644 index 000000000000..7cd81f036fdd --- /dev/null +++ b/apps/mobile/src/features/threads/composerSlashSkillSearch.ts @@ -0,0 +1,16 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; + +export function matchesSlashSkillQuery(skill: ServerProviderSkill, query: string): boolean { + if (!skill.enabled) return false; + const normalizedQuery = query.toLowerCase(); + const skillQuery = + normalizedQuery === "skill" + ? "" + : normalizedQuery.startsWith("skill:") + ? normalizedQuery.slice("skill:".length) + : normalizedQuery; + if (!skillQuery) return true; + return [skill.name, skill.displayName, skill.shortDescription, skill.description].some((value) => + value?.toLowerCase().includes(skillQuery), + ); +} diff --git a/apps/mobile/src/features/threads/markdownImageSize.test.ts b/apps/mobile/src/features/threads/markdownImageSize.test.ts new file mode 100644 index 000000000000..76170890d519 --- /dev/null +++ b/apps/mobile/src/features/threads/markdownImageSize.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + MARKDOWN_IMAGE_MAX_HEIGHT, + MARKDOWN_IMAGE_MAX_WIDTH, + resolveMarkdownImageDisplaySize, +} from "./markdownImageSize"; + +describe("resolveMarkdownImageDisplaySize", () => { + it("keeps small images at their intrinsic size", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 96, + sourceHeight: 96, + availableWidth: 332, + }), + ).toEqual({ width: 96, height: 96 }); + }); + + it("fits wide images to the available chat width", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 960, + sourceHeight: 540, + availableWidth: 332, + }), + ).toEqual({ width: 332, height: 186.75 }); + }); + + it("caps wide images at 480 points on larger screens", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 960, + sourceHeight: 540, + availableWidth: 900, + }), + ).toEqual({ width: MARKDOWN_IMAGE_MAX_WIDTH, height: 270 }); + }); + + it("caps tall images by height without changing their aspect ratio", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 400, + sourceHeight: 800, + availableWidth: 332, + }), + ).toEqual({ width: 240, height: MARKDOWN_IMAGE_MAX_HEIGHT }); + }); + + it("rejects dimensions that cannot produce a stable layout", () => { + expect( + resolveMarkdownImageDisplaySize({ sourceWidth: 0, sourceHeight: 100, availableWidth: 332 }), + ).toBeNull(); + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 100, + sourceHeight: Number.NaN, + availableWidth: 332, + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/markdownImageSize.ts b/apps/mobile/src/features/threads/markdownImageSize.ts new file mode 100644 index 000000000000..0fb6f8fbcc6d --- /dev/null +++ b/apps/mobile/src/features/threads/markdownImageSize.ts @@ -0,0 +1,37 @@ +export const MARKDOWN_IMAGE_MAX_WIDTH = 480; +export const MARKDOWN_IMAGE_MAX_HEIGHT = 480; + +export interface MarkdownImageDisplaySize { + readonly width: number; + readonly height: number; +} + +/** Keeps small images intrinsic while fitting larger images inside the chat viewport. */ +export function resolveMarkdownImageDisplaySize(input: { + readonly sourceWidth: number; + readonly sourceHeight: number; + readonly availableWidth: number; +}): MarkdownImageDisplaySize | null { + if ( + !Number.isFinite(input.sourceWidth) || + !Number.isFinite(input.sourceHeight) || + !Number.isFinite(input.availableWidth) || + input.sourceWidth <= 0 || + input.sourceHeight <= 0 || + input.availableWidth <= 0 + ) { + return null; + } + + const scale = Math.min( + 1, + input.availableWidth / input.sourceWidth, + MARKDOWN_IMAGE_MAX_WIDTH / input.sourceWidth, + MARKDOWN_IMAGE_MAX_HEIGHT / input.sourceHeight, + ); + + return { + width: input.sourceWidth * scale, + height: input.sourceHeight * scale, + }; +} diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx index 0c33da436a57..1895ef0d45ca 100644 --- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -1,5 +1,5 @@ import { SymbolView } from "../../components/AppSymbol"; -import { Pressable, StyleSheet } from "react-native"; +import { Pressable } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -10,31 +10,17 @@ export type SidebarFilterButtonIcon = export function SidebarFilterButton(props: { readonly accessibilityLabel: string; readonly icon: SidebarFilterButtonIcon; - /** Rendered inside a shared capsule group — no own background/border. */ - readonly grouped?: boolean; }) { const iconColor = useThemeColor("--color-foreground"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const idleBackgroundColor = useThemeColor("--color-glass-surface"); - const borderColor = useThemeColor("--color-header-border"); return ( [ - props.grouped - ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } - : { - backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, - borderColor, - borderWidth: StyleSheet.hairlineWidth, - }, - ]} > - + ); } diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx index b0f5f1131f68..9ce77f8991bd 100644 --- a/apps/mobile/src/features/threads/sidebar-header-actions.tsx +++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx @@ -1,43 +1,28 @@ import { SymbolView } from "../../components/AppSymbol"; -import { Pressable, StyleSheet, View } from "react-native"; +import { Pressable, View } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; export interface SidebarHeaderActionsProps { readonly onOpenSettings: () => void; - /** Rendered inside a shared capsule group — buttons drop their own chrome. */ - readonly grouped?: boolean; } function FallbackHeaderButton(props: { readonly accessibilityLabel: string; readonly icon: "gearshape" | "square.and.pencil"; - readonly grouped?: boolean; readonly onPress: () => void; }) { const iconColor = useThemeColor("--color-foreground"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const idleBackgroundColor = useThemeColor("--color-glass-surface"); - const borderColor = useThemeColor("--color-header-border"); return ( [ - props.grouped - ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } - : { - backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, - borderColor, - borderWidth: StyleSheet.hairlineWidth, - }, - ]} > - + ); } @@ -47,7 +32,6 @@ export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 8cc68cb3c525..2ea207923429 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -1,6 +1,71 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow"; +import { + resolveThreadFeedLiveFollow, + resolveThreadFeedSubmissionAnchor, +} from "./thread-feed-live-follow"; + +describe("resolveThreadFeedSubmissionAnchor", () => { + it("anchors the first user message in a thread", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: null, + submittedMessageId: "first-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBe("first-message"); + }); + + it("preserves the first-message anchor when another message is queued", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 1, + }), + ).toBe("first-message"); + }); + + it("preserves the first-message anchor after its outbox entry drains", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBe("first-message"); + }); + + it("does not anchor a follow-up after a user message appears", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: true, + queuedMessageCount: 0, + }), + ).toBeNull(); + }); + + it("does not anchor a thread that has already started a turn", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: null, + submittedMessageId: "second-message", + hasStartedTurn: true, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBeNull(); + }); +}); describe("resolveThreadFeedLiveFollow", () => { it("pauses immediately when the user starts scrolling", () => { diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index babe18f0c1cb..312fd67473e5 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -12,6 +12,24 @@ export type ThreadFeedLiveFollowEvent = readonly userScrollSessionActive: boolean; }; +export function resolveThreadFeedSubmissionAnchor(input: { + readonly currentAnchorMessageId: AnchorId | null; + readonly submittedMessageId: AnchorId; + readonly hasStartedTurn: boolean; + readonly hasUserMessage: boolean; + readonly queuedMessageCount: number; +}): AnchorId | null { + if (input.hasStartedTurn || input.hasUserMessage) { + return null; + } + + if (input.currentAnchorMessageId !== null) { + return input.currentAnchorMessageId; + } + + return input.queuedMessageCount > 0 ? null : input.submittedMessageId; +} + export function resolveThreadFeedLiveFollow( current: boolean, event: ThreadFeedLiveFollowEvent, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index fa1e752d619f..146779280003 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,11 +3,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import { - canSnooze, - resolveSnoozePresets, - type ChangeRequestSettleSource, -} from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; @@ -27,10 +23,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { + resolveThreadListV2ChangeRequestState, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, + type ThreadListV2ChangeRequestState, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -114,6 +112,7 @@ const SNOOZE_ACCENT_DARK = "#60a5fa"; export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { readonly count: number; + readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; @@ -126,11 +125,12 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS } accessibilityLabel={props.count === 1 ? "1 snoozed thread" : `${props.count} snoozed threads`} accessibilityRole="button" - accessibilityState={{ expanded: props.expanded }} + accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} + disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > @@ -151,6 +151,7 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { readonly count: number; + readonly disabled?: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; @@ -163,11 +164,12 @@ export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledS } accessibilityLabel={props.count === 1 ? "1 settled thread" : `${props.count} settled threads`} accessibilityRole="button" - accessibilityState={{ expanded: props.expanded }} + accessibilityState={{ disabled: props.disabled, expanded: props.expanded }} className={cn( "mb-1.5 mt-4 flex-row items-center gap-2.5", props.pane === "sidebar" ? "px-3" : "px-5", )} + disabled={props.disabled} onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > @@ -373,7 +375,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { merge and close rules. Mirrors web's onChangeRequestState. */ readonly onChangeRequestState?: ( threadKey: string, - changeRequest: ChangeRequestSettleSource | null, + changeRequest: ThreadListV2ChangeRequestState | null, ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; @@ -407,11 +409,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const prUpdatedAt = pr?.updatedAt ?? null; const threadKey = `${thread.environmentId}:${thread.id}`; useEffect(() => { - onChangeRequestState?.( - threadKey, - prState === null ? null : { state: prState, updatedAt: prUpdatedAt }, - ); - }, [onChangeRequestState, prState, prUpdatedAt, threadKey]); + const changeRequest = resolveThreadListV2ChangeRequestState({ + linkedPullRequest: thread.linkedPullRequest, + state: prState, + updatedAt: prUpdatedAt, + }); + if (changeRequest === undefined) return; + onChangeRequestState?.(threadKey, changeRequest); + }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); @@ -506,7 +511,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } satisfies MenuAction, ] : []), - pinnedRow + thread.pinnedAt != null ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] @@ -517,6 +522,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { props.canMovePinnedUp, props.pinReorderSupported, props.pinningSupported, + thread.pinnedAt, ], ); const titleRegenerationMenuItems = useMemo( @@ -552,8 +558,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [pinMenuItem, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( - () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SLIM_MENU_ACTIONS[0]!, + ...(thread.pinnedAt != null ? pinMenuItem : []), + ...titleRegenerationMenuItems, + SLIM_MENU_ACTIONS[1]!, + ], + [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c58dbb67517b..24c07eae6da1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, + resolveThreadListV2ChangeRequestState, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -53,6 +54,48 @@ function makeThread( } const NOW = "2026-06-02T00:00:00.000Z"; +const linkedPullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", +}; + +describe("resolveThreadListV2ChangeRequestState", () => { + it("preserves the previous state while a linked pull request reloads", () => { + expect( + resolveThreadListV2ChangeRequestState({ + linkedPullRequest, + state: null, + updatedAt: null, + }), + ).toBeUndefined(); + }); + + it("clears the previous state after a pull request is unlinked", () => { + expect( + resolveThreadListV2ChangeRequestState({ + linkedPullRequest: null, + state: null, + updatedAt: null, + }), + ).toBeNull(); + }); + + it("reports a loaded linked pull request", () => { + expect( + resolveThreadListV2ChangeRequestState({ + linkedPullRequest, + state: "merged", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + ).toEqual({ + state: "merged", + updatedAt: "2026-06-02T00:00:00.000Z", + linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', + }); + }); +}); describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { @@ -260,9 +303,74 @@ describe("sortThreadsForListV2", () => { ]); expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); }); + + it("surfaces an un-settled thread at the top via its re-entry stamp", () => { + const sorted = sortThreadsForListV2([ + { + id: "old-unsettled", + createdAt: "2026-06-01T08:00:00.000Z", + unsettledAt: "2026-06-01T13:00:00.000Z", + }, + { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]); + }); }); describe("buildThreadListV2Items", () => { + it("ignores the previous pull request state after a different pull request is linked", () => { + const thread = makeThread({ + id: ThreadId.make("linked"), + title: "Linked pull request", + linkedPullRequest, + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([ + [ + `${environmentId}:${thread.id}`, + { + state: "merged" as const, + linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]', + }, + ], + ]), + now: NOW, + }); + + expect(layout.settledCount).toBe(0); + expect(layout.items[0]?.variant).toBe("card"); + }); + + it("settles a thread only when the cached pull request identity matches", () => { + const thread = makeThread({ + id: ThreadId.make("linked-merged"), + title: "Linked merged pull request", + linkedPullRequest, + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([ + [ + `${environmentId}:${thread.id}`, + { + state: "merged" as const, + linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', + }, + ], + ]), + now: NOW, + }); + + expect(layout.settledCount).toBe(1); + expect(layout.items[0]?.variant).toBe("slim"); + }); + it("keeps a merged thread active when auto-settle on merge is off", () => { const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); const layout = buildThreadListV2Items({ @@ -309,7 +417,7 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(1); }); - it("renders pinned threads first and exempts them from auto-settle — parity with web", () => { + it("places settled pinned threads in the settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -317,7 +425,6 @@ describe("buildThreadListV2Items", () => { id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", - // Stale settled state (the decider clears it on pin): the pin wins. settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), @@ -327,8 +434,81 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]); - expect(layout.items.map((item) => item.pinned)).toEqual([true, false]); + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); + expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); + expect(layout.settledCount).toBe(1); + }); + + it("moves pinned threads to the settled shelf when their pull request merges", () => { + const merged = makeThread({ + id: ThreadId.make("pinned-merged"), + title: "Pinned merged pull request", + pinnedAt: "2026-06-01T12:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); + expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); + expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); + expect(layout.settledCount).toBe(1); + }); + + it("moves inactive pinned threads to the settled shelf", () => { + const inactive = makeThread({ + id: ThreadId.make("pinned-inactive"), + title: "Pinned inactive thread", + createdAt: "2026-05-20T00:00:00.000Z", + pinnedAt: "2026-05-21T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-inactive"), + state: "completed", + requestedAt: "2026-05-21T00:00:00.000Z", + startedAt: "2026-05-21T00:00:01.000Z", + completedAt: "2026-05-21T00:00:02.000Z", + assistantMessageId: null, + }, + }); + const layout = buildThreadListV2Items({ + threads: [inactive], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(layout.items[0]).toMatchObject({ + thread: { id: "pinned-inactive" }, + variant: "slim", + pinned: false, + }); + expect(layout.settledCount).toBe(1); + }); + + it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { + const merged = makeThread({ + id: ThreadId.make("pinned-merged"), + title: "Pinned merged pull request", + pinnedAt: "2026-06-01T12:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [merged], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), + autoSettleOnMerge: false, + now: NOW, + }); + + expect(layout.items[0]).toMatchObject({ + thread: { id: "pinned-merged" }, + variant: "card", + pinned: true, + }); expect(layout.settledCount).toBe(0); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 45079bac6e7f..be3343a21bad 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -12,8 +12,11 @@ import type { } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + activeThreadAnchorTimestampMs, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -30,6 +33,35 @@ export { snoozeWakeLabel }; export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; +export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { + readonly linkedPullRequestKey?: string | null; +} + +function linkedPullRequestKey( + linkedPullRequest: ThreadLinkedPullRequest | null | undefined, +): string | null { + if (linkedPullRequest == null) return null; + return JSON.stringify([ + linkedPullRequest.projectId, + linkedPullRequest.repository.toLowerCase(), + linkedPullRequest.number, + ]); +} + +/** Keep the previous linked PR state while its detail query reloads. */ +export function resolveThreadListV2ChangeRequestState(input: { + readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined; + readonly state: ChangeRequestSettleSource["state"] | null; + readonly updatedAt: string | null; +}): ThreadListV2ChangeRequestState | null | undefined { + if (input.state === null) return input.linkedPullRequest == null ? null : undefined; + return { + state: input.state, + updatedAt: input.updatedAt, + linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest), + }; +} + export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; @@ -162,19 +194,25 @@ function firstValidTimestampMs(...candidates: ReadonlyArray( - threads: readonly T[], -): T[] { +export function sortThreadsForListV2< + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + }, +>(threads: readonly T[]): T[] { // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 // change-by-copy array methods. return [...threads].sort( (left, right) => - parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || left.id.localeCompare(right.id), ); } @@ -322,7 +360,7 @@ export function buildThreadListV2Items(input: { readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; /** Per-row PR reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestByKey?: ReadonlyMap; + readonly changeRequestByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -384,12 +422,15 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const changeRequest = + const cachedChangeRequest = input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - // Visibility parity with web: snooze outranks everything, including a - // pin — a snoozed thread leaves the list until it wakes (or raises its - // hand). The pin (and its pinOrderKey) survives underneath, so a woken - // thread reappears at its exact spot in the pinned block. + const changeRequest = + cachedChangeRequest !== null && + (cachedChangeRequest.linkedPullRequestKey ?? null) === + linkedPullRequestKey(thread.linkedPullRequest) + ? cachedChangeRequest + : null; + // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -401,12 +442,6 @@ export function buildThreadListV2Items(input: { } continue; } - // A pin otherwise overrides the lifecycle: pinned threads render above - // the inbox and never auto-settle out of sight. - if (thread.pinnedAt != null) { - pinned.push(thread); - continue; - } if ( supportsSettlement && effectiveSettled(thread, { @@ -417,6 +452,8 @@ export function buildThreadListV2Items(input: { }) ) { settled.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); } else { active.push(thread); } diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts new file mode 100644 index 000000000000..d45993364721 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -0,0 +1,45 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback, useRef } from "react"; + +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; + +/** + * Shared persisted shelf state for the compact Home list and iPad sidebar. + * Refs advance before persistence starts so consecutive presses always toggle + * the latest value, even if React has not rendered the optimistic patch yet. + */ +export function useThreadListV2ShelfPreferences() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); + const snoozedShelfExpanded = + loaded && preferencesResult.value.threadListV2SnoozedShelfExpanded === true; + const settledShelfExpanded = + !loaded || preferencesResult.value.threadListV2SettledShelfExpanded !== false; + const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); + const settledShelfExpandedRef = useRef(settledShelfExpanded); + snoozedShelfExpandedRef.current = snoozedShelfExpanded; + settledShelfExpandedRef.current = settledShelfExpanded; + + const toggleSnoozedShelf = useCallback(() => { + if (!loaded) return; + const expanded = !snoozedShelfExpandedRef.current; + snoozedShelfExpandedRef.current = expanded; + savePreferences({ threadListV2SnoozedShelfExpanded: expanded }); + }, [loaded, savePreferences]); + const toggleSettledShelf = useCallback(() => { + if (!loaded) return; + const expanded = !settledShelfExpandedRef.current; + settledShelfExpandedRef.current = expanded; + savePreferences({ threadListV2SettledShelfExpanded: expanded }); + }, [loaded, savePreferences]); + + return { + loaded, + settledShelfExpanded, + snoozedShelfExpanded, + toggleSettledShelf, + toggleSnoozedShelf, + } as const; +} diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index 09897b6186e1..992beed3abe4 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -1,10 +1,18 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; +import * as Device from "expo-device"; import { Platform } from "react-native"; -export function authClientMetadata(): AuthClientPresentationMetadata { +export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { + const osMajorVersion = Number.parseInt(Device.osVersion?.split(".")[0] ?? "", 10); + const deviceModel = Device.modelName?.trim(); + return { label: "T3 Code Mobile", deviceType: "mobile", ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), + ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), + ...(deviceModel ? { deviceModel } : {}), + surface: "mobile", + ...(appVersion ? { appVersion } : {}), }; } diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index f1f30b298b66..f474c7e6ea4f 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,12 +1,18 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, - authClientMetadata, redactPairingCredential, toStableSavedRemoteConnection, } from "./connection"; +import { authClientMetadata } from "./authClientMetadata"; + +const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); +const mobileDevice = vi.hoisted(() => ({ + osVersion: "18.4.1", + modelName: "iPhone 15 Pro", +})); vi.mock("./runtime", () => ({ runtime: { @@ -15,17 +21,45 @@ vi.mock("./runtime", () => ({ })); vi.mock("react-native", () => ({ - Platform: { - OS: "ios", - }, + Platform: mobilePlatform, })); +vi.mock("expo-device", () => mobileDevice); + describe("mobile remote connection records", () => { + afterEach(() => { + mobilePlatform.OS = "ios"; + mobileDevice.osVersion = "18.4.1"; + mobileDevice.modelName = "iPhone 15 Pro"; + }); + it("identifies mobile token exchanges for authorized-client presentation", () => { expect(authClientMetadata()).toEqual({ label: "T3 Code Mobile", deviceType: "mobile", os: "iOS", + osMajorVersion: 18, + deviceModel: "iPhone 15 Pro", + surface: "mobile", + }); + }); + + it("includes only the Android major version and hardware model", () => { + mobilePlatform.OS = "android"; + mobileDevice.osVersion = "15.2.1"; + mobileDevice.modelName = "Pixel 9"; + + expect(authClientMetadata()).toMatchObject({ + os: "Android", + osMajorVersion: 15, + deviceModel: "Pixel 9", + }); + }); + + it("includes the mobile app version when the client provides it", () => { + expect(authClientMetadata("1.2.3")).toMatchObject({ + surface: "mobile", + appVersion: "1.2.3", }); }); diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index 839bc70e6d95..df26a192cd0f 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -2,8 +2,6 @@ import { EnvironmentId } from "@t3tools/contracts"; import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -export { authClientMetadata } from "./authClientMetadata"; - export interface SavedRemoteConnection { readonly environmentId: EnvironmentId; readonly environmentLabel: string; diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 6dea0beafbec..b1722a137c81 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -7,11 +7,34 @@ import { deriveFileInspectorPaneLayout, deriveLayout, deriveStableFormSheetDetent, + deriveThreadFeedInitialContentInset, deriveWorkspacePaneLayout, SPLIT_LAYOUT_MIN_HEIGHT, SPLIT_LAYOUT_MIN_WIDTH, } from "./layout"; +describe("deriveThreadFeedInitialContentInset", () => { + it("seeds Android scroll math with the composer overlay estimate", () => { + expect( + deriveThreadFeedInitialContentInset({ + platform: "android", + usesNativeAutomaticInsets: false, + bottomContentInset: 174, + }), + ).toEqual({ bottom: 174 }); + }); + + it("does not double native iOS insets", () => { + expect( + deriveThreadFeedInitialContentInset({ + platform: "ios", + usesNativeAutomaticInsets: true, + bottomContentInset: 174, + }), + ).toBeUndefined(); + }); +}); + describe("resizable pane constraints", () => { it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index eb0c45e0607d..33438a324c12 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -52,6 +52,18 @@ export interface FileInspectorPaneLayout { readonly width: number | null; } +export function deriveThreadFeedInitialContentInset(input: { + readonly platform: string; + readonly usesNativeAutomaticInsets: boolean; + readonly bottomContentInset: number; +}): { readonly bottom: number } | undefined { + if (input.platform !== "android" || input.usesNativeAutomaticInsets) { + return undefined; + } + + return { bottom: Math.max(0, input.bottomContentInset) }; +} + export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index ff57287b7412..49a8b46648e1 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -50,6 +50,24 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); + it.each(["md", "html", "xml"])("recognizes a bare spaced .%s filename", (extension) => { + expect( + resolveMarkdownLinkPresentation(`Updated%20cutover%20checklist.${extension}`), + ).toMatchObject({ + kind: "file", + path: `Updated cutover checklist.${extension}`, + label: `Updated cutover checklist.${extension}`, + }); + }); + + it("recognizes spaced relative paths", () => { + expect(resolveMarkdownLinkPresentation("docs/My%20Folder/checklist.xml")).toMatchObject({ + kind: "file", + path: "docs/My Folder/checklist.xml", + label: "checklist.xml", + }); + }); + it("extracts line fragments from relative file links", () => { expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({ kind: "file", diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 7b94dc629154..fe022c1191ae 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -207,6 +207,40 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); + it("persists Thread List v2 shelf expansion preferences", async () => { + await expect( + savePreferencesPatch({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }), + ).resolves.toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + + await expect(loadPreferences()).resolves.toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ + threadListV2SettledShelfExpanded: false, + threadListV2SnoozedShelfExpanded: true, + }); + }); + + it("ignores invalid Thread List v2 shelf expansion preference types", async () => { + mocks.setPreferencesJson( + JSON.stringify({ + baseFontSize: 17, + threadListV2SettledShelfExpanded: "false", + threadListV2SnoozedShelfExpanded: 1, + }), + 10, + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + }); + it("reconciles fallback preferences after SQLite recovers", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); await mocks.setItemAsync( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e1d46fd858e9..e2943ebc1a0d 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import { EventId, @@ -14,6 +15,7 @@ import { import { buildPendingUserInputAnswers, buildThreadFeed, + derivePendingApprovals, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, @@ -22,6 +24,34 @@ import { type ThreadFeedEntry, } from "./threadActivity"; +describe("Codex feedback pseudo-messages", () => { + it("keeps pending and completed feedback messages in the mobile thread body", () => { + const pending = { + id: MessageId.make("feedback-command"), + command: "/feedback The agent stopped early.", + createdAt: "2026-08-23T00:00:00.000Z", + status: "uploading" as const, + }; + const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map( + (message) => ({ + type: "message" as const, + id: message.id, + createdAt: message.createdAt, + message, + }), + ); + + expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries); + expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI..."); + + const completed = codexFeedbackMessage( + { ...pending, status: "sent", feedbackId: "codex-thread-1" }, + "assistant", + ); + expect(completed.text).toContain("codex-thread-1"); + }); +}); + const singleSelectQuestion = { id: "runtime", header: "Runtime", @@ -113,6 +143,59 @@ describe("pending user input answers", () => { }); }); +describe("pending approvals", () => { + it("keeps app access approvals and persistence choices from remote environments", () => { + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ]; + const activity = makeActivity({ + id: EventId.make("approval-safari"), + kind: "approval.requested", + summary: "App access approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { + requestId: "req-safari", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }); + + expect(derivePendingApprovals([activity])).toEqual([ + { + requestId: "req-safari", + requestKind: "mcp-elicitation", + createdAt: "2026-08-24T00:00:00.000Z", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + ]); + }); + + it("removes an app access approval after a remote client rejects it", () => { + const requested = makeActivity({ + id: EventId.make("approval-safari-open"), + kind: "approval.requested", + summary: "App access approval requested", + createdAt: "2026-08-24T00:00:00.000Z", + payload: { requestId: "req-safari", requestKind: "mcp-elicitation" }, + }); + const resolved = makeActivity({ + id: EventId.make("approval-safari-resolved"), + kind: "approval.resolved", + summary: "Approval resolved", + createdAt: "2026-08-24T00:00:01.000Z", + payload: { requestId: "req-safari", decision: "decline" }, + }); + + expect(derivePendingApprovals([requested, resolved])).toEqual([]); + }); +}); + function makeActivity( input: Partial & Pick, @@ -151,6 +234,44 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps older local feedback before newer messages returned by the server", () => { + const submission = { + id: MessageId.make("feedback-command-ordering"), + command: "/feedback The agent stopped early.", + createdAt: "2026-08-23T00:00:01.000Z", + status: "sent" as const, + feedbackId: "codex-thread-1", + }; + const laterMessage = { + id: MessageId.make("later-server-message"), + role: "assistant" as const, + text: "Newer server response", + turnId: null, + createdAt: "2026-08-23T00:00:02.000Z", + updatedAt: "2026-08-23T00:00:02.000Z", + streaming: false, + }; + const thread = makeThread({ + id: ThreadId.make("thread-feedback-ordering"), + projectId: ProjectId.make("project-1"), + title: "Feedback ordering", + messages: [laterMessage], + }); + + const feed = buildThreadFeed(thread, { + localMessages: [ + codexFeedbackMessage(submission), + codexFeedbackMessage(submission, "assistant"), + ], + }); + + expect(feed.map((entry) => entry.id)).toEqual([ + "feedback-command-ordering", + "feedback-command-ordering:feedback", + "later-server-message", + ]); + }); + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), @@ -371,7 +492,7 @@ describe("buildThreadFeed", () => { expect(serializedToolOutputs).toBe(1); }); - it("folds settled turn work while leaving the terminal answer visible", () => { + it("keeps the first and terminal assistant messages visible around settled work", () => { const turnId = TurnId.make("turn-1"); const thread = makeThread({ id: ThreadId.make("thread-3"), @@ -387,9 +508,9 @@ describe("buildThreadFeed", () => { }, messages: [ { - id: MessageId.make("assistant-commentary"), + id: MessageId.make("assistant-first"), role: "assistant", - text: "I am checking.", + text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.", turnId, streaming: false, createdAt: "2026-04-01T00:00:02.000Z", @@ -424,8 +545,12 @@ describe("buildThreadFeed", () => { const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); - expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); - expect(collapsed[0]).toMatchObject({ + expect(collapsed.map((entry) => entry.id)).toEqual([ + "assistant-first", + "turn-fold:turn-1", + "assistant-final", + ]); + expect(collapsed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 17s", expanded: false, @@ -433,13 +558,68 @@ describe("buildThreadFeed", () => { const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); expect(expanded.map((entry) => entry.id)).toEqual([ + "assistant-first", "turn-fold:turn-1", - "assistant-commentary", "tool-completed", "assistant-final", ]); }); + it("folds assistant messages between the first and terminal messages", () => { + const turnId = TurnId.make("turn-1"); + const thread = makeThread({ + id: ThreadId.make("thread-middle-message"), + projectId: ProjectId.make("project-1"), + title: "Bounded narration", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:06.000Z", + assistantMessageId: MessageId.make("assistant-final"), + }, + messages: [ + { + id: MessageId.make("assistant-first"), + role: "assistant", + text: "The main result is ready.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + { + id: MessageId.make("assistant-middle"), + role: "assistant", + text: "I am checking one more detail.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:03.000Z", + updatedAt: "2026-04-01T00:00:04.000Z", + }, + { + id: MessageId.make("assistant-final"), + role: "assistant", + text: "Verification finished.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:05.000Z", + updatedAt: "2026-04-01T00:00:06.000Z", + }, + ], + }); + + const feed = buildThreadFeed(thread); + const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + + expect(rows.map((entry) => entry.id)).toEqual([ + "assistant-first", + "turn-fold:turn-1", + "assistant-final", + ]); + }); + it("measures a steer-superseded turn from its user boundary through trailing work", () => { const firstTurnId = TurnId.make("turn-1"); const secondTurnId = TurnId.make("turn-2"); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2a..9e0cb64ae8b3 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,4 +1,9 @@ -import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; +import { + ApprovalRequestId, + isToolLifecycleItemType, + ProviderApprovalOption, + ProviderRequestKind, +} from "@t3tools/contracts"; import type { OrchestrationLatestTurn, OrchestrationThread, @@ -11,14 +16,20 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; +import * as Schema from "effect/Schema"; export interface PendingApproval { readonly requestId: ApprovalRequestId; - readonly requestKind: "command" | "file-read" | "file-change"; + readonly requestKind: ProviderRequestKind; readonly createdAt: string; readonly detail?: string; + readonly appName?: string; + readonly options?: ReadonlyArray; } +const isProviderRequestKind = Schema.is(ProviderRequestKind); +const isProviderApprovalOption = Schema.is(ProviderApprovalOption); + export interface PendingUserInput { readonly requestId: ApprovalRequestId; readonly createdAt: string; @@ -147,6 +158,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "mcp_elicitation_approval": + return "mcp-elicitation"; default: return null; } @@ -1144,9 +1157,13 @@ function deriveThreadFeedTurnFolds( feed: ReadonlyArray, latestTurn: ThreadFeedLatestTurn | null, ): ReadonlyMap { + const firstAssistantMessageIdByTurn = new Map(); const terminalAssistantMessageIdByTurn = new Map(); for (const entry of feed) { if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + if (!firstAssistantMessageIdByTurn.has(entry.message.turnId)) { + firstAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); + } terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); } } @@ -1194,17 +1211,24 @@ function deriveThreadFeedTurnFolds( continue; } + const firstAssistantMessageId = firstAssistantMessageIdByTurn.get(turnId); const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); const hiddenEntryIds = new Set( - entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), + entries + .filter( + (entry) => + entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId, + ) + .map((entry) => entry.id), ); if (hiddenEntryIds.size === 0) { continue; } const firstEntry = entries[0]; + const firstHiddenEntry = entries.find((entry) => hiddenEntryIds.has(entry.id)); const lastEntry = entries.at(-1); - if (!firstEntry || !lastEntry) { + if (!firstEntry || !firstHiddenEntry || !lastEntry) { continue; } const terminalEntry = terminalAssistantMessageId @@ -1233,9 +1257,9 @@ function deriveThreadFeedTurnFolds( ? `Worked for ${duration}` : "Worked"; - foldsByAnchorId.set(firstEntry.id, { + foldsByAnchorId.set(firstHiddenEntry.id, { turnId, - createdAt: firstEntry.createdAt, + createdAt: firstHiddenEntry.createdAt, hiddenEntryIds, label, }); @@ -1364,13 +1388,14 @@ export function derivePendingApprovals( ? (activity.payload as Record) : null; const requestId = parseApprovalRequestId(payload?.requestId); - const requestKind = - payload?.requestKind === "command" || - payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" - ? payload.requestKind - : requestKindFromRequestType(payload?.requestType); + const requestKind = isProviderRequestKind(payload?.requestKind) + ? payload.requestKind + : requestKindFromRequestType(payload?.requestType); const detail = typeof payload?.detail === "string" ? payload.detail : undefined; + const appName = typeof payload?.appName === "string" ? payload.appName : undefined; + const options = Array.isArray(payload?.options) + ? payload.options.filter(isProviderApprovalOption) + : undefined; if (activity.kind === "approval.requested" && requestId && requestKind) { openByRequestId.set(requestId, { @@ -1378,6 +1403,8 @@ export function derivePendingApprovals( requestKind, createdAt: activity.createdAt, ...(detail ? { detail } : {}), + ...(appName ? { appName } : {}), + ...(options && options.length > 0 ? { options } : {}), }); continue; } @@ -1515,15 +1542,19 @@ export function buildThreadFeed( thread: OrchestrationThread, options?: { readonly loadedMessages?: ReadonlyArray; + readonly localMessages?: ReadonlyArray; }, ): ThreadFeedEntry[] { const loadedMessages = options?.loadedMessages ?? thread.messages; + const messages = options?.localMessages + ? [...loadedMessages, ...options.localMessages] + : loadedMessages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; const workLogEntries = deriveWorkLogEntries(thread.activities); const entries = Arr.sortWith( [ - ...loadedMessages.map((message) => ({ + ...messages.map((message) => ({ type: "message", id: message.id, createdAt: message.createdAt, diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 488766f36954..7c2c037eed33 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter" type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "@t3tools/mobile-markdown-text/types"; diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 403f32a1de48..7ee4d21b1560 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/ type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "@t3tools/mobile-markdown-text/types"; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index dfaeab9cd6ba..5d0bd8a3c9dc 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -42,6 +42,10 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + /** Undefined preserves the default expanded Settled shelf. */ + readonly threadListV2SettledShelfExpanded?: boolean; + /** Undefined preserves the default collapsed Snoozed shelf. */ + readonly threadListV2SnoozedShelfExpanded?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -100,6 +104,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + threadListV2SettledShelfExpanded?: boolean; + threadListV2SnoozedShelfExpanded?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -170,6 +176,12 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (typeof parsed.threadListV2SettledShelfExpanded === "boolean") { + preferences.threadListV2SettledShelfExpanded = parsed.threadListV2SettledShelfExpanded; + } + if (typeof parsed.threadListV2SnoozedShelfExpanded === "boolean") { + preferences.threadListV2SnoozedShelfExpanded = parsed.threadListV2SnoozedShelfExpanded; + } return preferences; } diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index b8b827585ea2..611a1ed8b99b 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -12,18 +12,35 @@ const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)) Atom.withLabel("mobile-asset-url:empty"), ); -export function useAssetUrl( +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure" } + | { readonly _tag: "Success"; readonly url: string }; + +export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, -): string | null { +): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); + if (result._tag === "Failure") { + return { _tag: "Failure" }; + } if (preparedConnection._tag === "None" || result._tag !== "Success") { - return null; + return { _tag: "Loading" }; } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; +} + +export function useAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): string | null { + const state = useAssetUrlState(environmentId, resource); + return state._tag === "Success" ? state.url : null; } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 721c82a0e38e..dd7ace60ad99 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert } from "react-native"; +import * as Cause from "effect/Cause"; import { CommandId, @@ -11,6 +13,13 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + codexFeedbackMessage, + parseCodexFeedbackCommand, + submitCodexFeedback, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; @@ -21,6 +30,7 @@ import { } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { buildThreadFeed } from "../lib/threadActivity"; import { appAtomRegistry } from "../state/atom-registry"; import { @@ -41,6 +51,8 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { threadEnvironment } from "./threads"; +import { useAtomCommand } from "./use-atom-command"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -74,10 +86,16 @@ export function useThreadDraftForThread(input: { } export function useThreadComposerState() { - const { selectedThread: selectedThreadShell } = useThreadSelection(); + const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< + Record> + >({}); + const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + reportFailure: false, + }); useEffect(() => { ensureComposerDraftsLoaded(); @@ -90,10 +108,21 @@ export function useThreadComposerState() { () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], ); - const selectedThreadFeed = useMemo( - () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []), - [selectedThreadDetail], - ); + const selectedThreadFeed = useMemo(() => { + if (!selectedThreadDetail) { + return []; + } + const submissions = selectedThreadKey + ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) + : []; + return buildThreadFeed(selectedThreadDetail, { + localMessages: submissions.flatMap((submission) => + submission.status === "interrupted" + ? [] + : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], + ), + }); + }, [feedbackSubmissionsByThreadKey, selectedThreadDetail, selectedThreadKey]); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; const draftMessage = selectedDraft?.text ?? ""; @@ -143,6 +172,70 @@ export function useThreadComposerState() { return null; } + const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( + (entry) => entry.instanceId === thread.modelSelection.instanceId, + ); + const feedbackCommand = + attachments.length === 0 && + (provider?.driver === "codex" || thread.session?.providerName === "codex") + ? parseCodexFeedbackCommand(text) + : null; + if (feedbackCommand) { + if (thread.session === null) { + Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); + return null; + } + const metadata = makeQueuedMessageMetadata(); + const result = await submitCodexFeedback({ + submission: { + id: MessageId.make(metadata.messageId), + command: text, + createdAt: metadata.createdAt, + }, + clearDraft: () => clearComposerDraftContent(threadKey), + onUpdate: (submission) => { + setFeedbackSubmissionsByThreadKey((current) => { + const existing = current[threadKey] ?? []; + const found = existing.some((entry) => entry.id === submission.id); + return { + ...current, + [threadKey]: found + ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) + : [...existing, submission], + }; + }); + }, + upload: () => + uploadThreadFeedback({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + ...feedbackCommand, + }, + }), + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return null; + } + const error = Cause.squash(result.cause); + Alert.alert( + "Could not send feedback to OpenAI", + error instanceof Error ? error.message : "An error occurred.", + ); + return null; + } + const feedbackId = result.value.feedbackId; + Alert.alert("Feedback sent to OpenAI", `Thread ID: ${feedbackId}`, [ + { text: "OK", style: "cancel" }, + { + text: "Copy ID", + onPress: () => copyTextWithHaptic(feedbackId, { target: "Codex feedback thread ID" }), + }, + ]); + return null; + } + const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); // Enqueue publishes the queued atom synchronously (the durable write @@ -175,7 +268,12 @@ export function useThreadComposerState() { ); }); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [ + selectedEnvironmentRuntime?.serverConfig?.providers, + selectedThreadDetail, + selectedThreadShell, + uploadThreadFeedback, + ]); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index a3440cd4848e..0c10d7b3fa41 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,9 +1,16 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + createLinkedPullRequestDetailAtomFamily, + pullRequestDetailToVcsStatus, +} from "@t3tools/client-runtime/state/pull-requests"; +import { connectionAtomRuntime } from "../connection/runtime"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; import { vcsEnvironment } from "./vcs"; +const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); + export { presentThreadPr, type ThreadPr, @@ -22,13 +29,36 @@ export function useThreadPr( ): ThreadPrPresentation | null { const cwd = thread.worktreePath ?? projectCwd; const gitStatus = useEnvironmentQuery( - thread.branch !== null && cwd !== null + thread.linkedPullRequest == null && thread.branch !== null && cwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd }, }) : null, ); + const linkedPullRequest = useEnvironmentQuery( + thread.linkedPullRequest == null + ? null + : linkedPullRequestDetailAtom({ + environmentId: thread.environmentId, + input: { + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }, + }), + ); + + if (thread.linkedPullRequest != null) { + const detail = linkedPullRequest.data; + return detail === null + ? null + : presentThreadPr(pullRequestDetailToVcsStatus(detail), { + kind: detail.provider, + name: detail.provider, + baseUrl: "", + }); + } const status = gitStatus.data; if (status === null || thread.branch === null || status.refName !== thread.branch) { diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index faaf3e23c77c..cf454eadbc65 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -8,6 +8,7 @@ import { ProviderDriverKind, type OrchestrationEvent, type OrchestrationThread, + type ProviderApprovalDecision, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -201,14 +202,14 @@ export interface OrchestrationIntegrationHarness { requestId: string, predicate: (row: { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; }) => boolean, timeoutMs?: number, ) => Effect.Effect< { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; }, never @@ -506,7 +507,7 @@ export const makeOrchestrationIntegrationHarness = ( row, ): row is { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; } => row !== null && predicate(row), `pending approval '${requestId}'`, @@ -514,7 +515,7 @@ export const makeOrchestrationIntegrationHarness = ( ) as Effect.Effect< { readonly status: "pending" | "resolved"; - readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; + readonly decision: ProviderApprovalDecision | null; readonly resolvedAt: string | null; }, never diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts new file mode 100644 index 000000000000..b84a74ec9cf2 --- /dev/null +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -0,0 +1,363 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EnvironmentId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { HttpServer } from "effect/unstable/http"; + +import * as EnvironmentAuth from "../src/auth/EnvironmentAuth.ts"; +import * as ServiceLauncherClient from "../src/cloud/serviceLauncherClient.ts"; +import * as ServerConfig from "../src/config.ts"; +import * as ServerEnvironment from "../src/environment/ServerEnvironment.ts"; +import * as Keybindings from "../src/keybindings.ts"; +import { OrchestrationLayerLive } from "../src/orchestration/runtimeLayer.ts"; +import * as OrchestrationEngine from "../src/orchestration/Services/OrchestrationEngine.ts"; +import * as OrchestrationReactor from "../src/orchestration/Services/OrchestrationReactor.ts"; +import * as ProjectionSnapshotQuery from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; +import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; +import * as ExternalLauncher from "../src/process/externalLauncher.ts"; +import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import * as ProviderService from "../src/provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "../src/provider/Services/ProviderSessionDirectory.ts"; +import * as ProviderSessionReaper from "../src/provider/Services/ProviderSessionReaper.ts"; +import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; +import * as ServerLifecycleEvents from "../src/serverLifecycleEvents.ts"; +import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts"; +import * as ServerSettings from "../src/serverSettings.ts"; +import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; +import * as WorkspacePaths from "../src/workspace/WorkspacePaths.ts"; + +const providerInstanceId = ProviderInstanceId.make("codex"); +const projectId = ProjectId.make("project-startup-orphan"); +const threadId = ThreadId.make("thread-startup-orphan"); +const stoppedBindingThreadId = ThreadId.make("thread-startup-orphan-stopped-binding"); +const resumeCursor = { schemaVersion: 1, sessionId: "provider-session-before-restart" }; +const stoppedBindingResumeCursor = { + schemaVersion: 1, + sessionId: "provider-session-stopped-before-restart", +}; + +const makePersistedRuntimeLayer = (dbPath: string) => { + const persistence = makeSqlitePersistenceLive(dbPath); + const orchestration = OrchestrationLayerLive.pipe( + Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(persistence), + ); + const directory = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + Layer.provide(persistence), + ); + return Layer.mergeAll(orchestration, directory); +}; + +const startupDependencies = Layer.mergeAll( + Layer.mock(Keybindings.Keybindings)({ + start: Effect.void, + }), + ServerSettings.layerTest(), + Layer.succeed(OrchestrationReactor.OrchestrationReactor, { + start: () => Effect.void, + }), + Layer.succeed(ProviderSessionReaper.ProviderSessionReaper, { + start: () => Effect.void, + }), + ServerLifecycleEvents.layer, + WorkspacePaths.layer, + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-startup-orphan")), + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("environment-startup-orphan"), + label: "Startup orphan test", + version: "test", + platform: { os: "linux", arch: "x64" }, + capabilities: {}, + } as never), + }), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + issueStartupPairingUrl: (baseUrl: string) => Effect.succeed(`${baseUrl}/pair`), + }), + Layer.mock(ExternalLauncher.ExternalLauncher)({ + launchBrowser: () => Effect.void, + }), + Layer.succeed(ServiceLauncherClient.ServiceLauncherClient, { + managed: false, + requestUpdate: () => Effect.die("unused"), + prepareTrial: Effect.sync(() => undefined), + }), + Layer.succeed( + HttpServer.HttpServer, + HttpServer.HttpServer.of({ + address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 3773 }, + serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], + }), + ), + AnalyticsService.layerTest, + Layer.succeed(ProviderService.ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + rollbackConversationTo: () => Effect.die("unused"), + discardTransientThread: () => Effect.die("unused"), + uploadFeedback: () => Effect.die("unused"), + streamEvents: Stream.empty, + }), +); + +it.effect( + "recovers a persisted starting session before opening the command gate after restart", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const firstRuntime = makePersistedRuntimeLayer(config.dbPath); + const now = yield* DateTime.now; + const createdAt = DateTime.formatIso(now); + + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("command-create-project"), + projectId, + title: "Startup orphan project", + workspaceRoot: "/tmp/startup-orphan-project", + defaultModelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("command-create-thread"), + threadId, + projectId, + title: "Startup orphan thread", + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("command-start-pending-turn"), + threadId, + message: { + messageId: MessageId.make("message-pending-before-restart"), + role: "user", + text: "Persist this queued turn before restart", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("command-mark-session-starting"), + threadId, + session: { + threadId, + status: "starting", + providerName: "codex", + providerInstanceId, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running", + resumeCursor, + runtimePayload: { activeTurnId: null, unrelated: "preserve-me" }, + runtimeMode: "full-access", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("command-create-stopped-binding-thread"), + threadId: stoppedBindingThreadId, + projectId, + title: "Startup orphan with stopped binding", + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("command-start-stopped-binding-pending-turn"), + threadId: stoppedBindingThreadId, + message: { + messageId: MessageId.make("message-stopped-binding-pending-before-restart"), + role: "user", + text: "Persist another queued turn before restart", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("command-mark-stopped-binding-session-starting"), + threadId: stoppedBindingThreadId, + session: { + threadId: stoppedBindingThreadId, + status: "starting", + providerName: "codex", + providerInstanceId, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + yield* directory.upsert({ + threadId: stoppedBindingThreadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "stopped", + resumeCursor: stoppedBindingResumeCursor, + runtimePayload: { activeTurnId: "stale", unrelated: "also-preserve-me" }, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(firstRuntime)); + + const secondRuntime = makePersistedRuntimeLayer(config.dbPath); + const startupLayer = ServerRuntimeStartup.layer.pipe( + Layer.provideMerge(secondRuntime), + Layer.provideMerge(startupDependencies), + ); + + const result = yield* Effect.gen(function* () { + const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const sql = yield* SqlClient.SqlClient; + + yield* startup.markHttpListening; + yield* startup.awaitCommandReady; + + const restartedThread = Option.getOrThrow(yield* query.getThreadDetailById(threadId)); + const restartedStoppedBindingThread = Option.getOrThrow( + yield* query.getThreadDetailById(stoppedBindingThreadId), + ); + const pendingRows = yield* sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_turns + WHERE thread_id IN (${threadId}, ${stoppedBindingThreadId}) + AND turn_id IS NULL + AND state = 'pending' + `; + const settleExit = yield* Effect.exit( + engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("command-settle-after-restart"), + threadId, + }), + ); + const snoozeExit = yield* Effect.exit( + engine.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("command-snooze-after-restart"), + threadId, + snoozedUntil: DateTime.formatIso(DateTime.add(now, { hours: 1 })), + }), + ); + const newTurnExit = yield* Effect.exit( + engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("command-new-turn-after-restart"), + threadId, + message: { + messageId: MessageId.make("message-new-turn-after-restart"), + role: "user", + text: "Continue immediately after restart", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt, + }), + ); + const binding = Option.getOrThrow(yield* directory.getBinding(threadId)); + const stoppedBinding = Option.getOrThrow( + yield* directory.getBinding(stoppedBindingThreadId), + ); + + return { + sessionStatus: restartedThread.session?.status, + activeTurnId: restartedThread.session?.activeTurnId, + latestTurn: restartedThread.latestTurn, + pendingTurnCount: pendingRows.length, + settleSucceeded: Exit.isSuccess(settleExit), + snoozeSucceeded: Exit.isSuccess(snoozeExit), + newTurnSucceeded: Exit.isSuccess(newTurnExit), + bindingStatus: binding.status, + resumeCursor: binding.resumeCursor, + runtimePayload: binding.runtimePayload, + stoppedBindingSessionStatus: restartedStoppedBindingThread.session?.status, + stoppedBindingStatus: stoppedBinding.status, + stoppedBindingResumeCursor: stoppedBinding.resumeCursor, + stoppedBindingRuntimePayload: stoppedBinding.runtimePayload, + }; + }).pipe(Effect.provide(startupLayer)); + + assert.deepStrictEqual(result, { + sessionStatus: "error", + activeTurnId: null, + latestTurn: null, + pendingTurnCount: 0, + settleSucceeded: true, + snoozeSucceeded: true, + newTurnSucceeded: true, + bindingStatus: "stopped", + resumeCursor, + runtimePayload: { activeTurnId: null, unrelated: "preserve-me" }, + stoppedBindingSessionStatus: "error", + stoppedBindingStatus: "stopped", + stoppedBindingResumeCursor, + stoppedBindingRuntimePayload: { + activeTurnId: null, + unrelated: "also-preserve-me", + }, + }); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-orphaned-provider-session-startup-", + }).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), +); diff --git a/apps/server/package.json b/apps/server/package.json index eb4dc7dd35ec..ca19f7085f9d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.33", + "version": "0.0.34", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 0a1972c2827c..aa47a78238bb 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -287,6 +287,44 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues an exact capability for a saved favicon outside the workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-workspace-", + }); + const pictures = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-pictures-", + }); + const externalPath = path.join(pictures, "custom.png"); + const siblingPath = path.join(pictures, "sibling.png"); + yield* fileSystem.writeFile(externalPath, new Uint8Array([1, 2, 3])); + yield* fileSystem.writeFile(siblingPath, new Uint8Array([4, 5, 6])); + const canonicalPath = yield* fileSystem.realPath(externalPath); + const canonicalSiblingPath = yield* fileSystem.realPath(siblingPath); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: externalPath, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect(result.sourcePath).toBe(externalPath); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.png$/); + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), + ).toEqual({ kind: "file", path: canonicalPath }); + const tamperedSuffixResult = yield* resolveAsset( + suffix.slice(0, separatorIndex), + "sibling.png", + ); + expect(tamperedSuffixResult).toEqual({ kind: "file", path: canonicalPath }); + expect(tamperedSuffixResult).not.toEqual({ kind: "file", path: canonicalSiblingPath }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("ignores a client favicon path hint", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 7157513b14d3..232a41e5a9c8 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -88,6 +88,12 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.NullOr(Schema.String), expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("project-favicon-external"), + filePath: Schema.String, + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -124,6 +130,17 @@ const optionOnNotFound = ( }), ); +const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* ( + filePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const canonicalFile = yield* optionOnNotFound(fileSystem.realPath(filePath)); + if (Option.isNone(canonicalFile)) return null; + + const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value)); + return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null; +}); + const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; @@ -300,13 +317,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) { + const isExternalOverride = + faviconPath !== null && + input.projectFaviconPath !== undefined && + path.isAbsolute(input.projectFaviconPath) && + path.normalize(faviconPath) === path.normalize(input.projectFaviconPath); + const relativePath = + faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null; + const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath; + if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } - sourcePath = relativePath ?? undefined; - const canonicalFaviconPath = relativePath - ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + sourcePath = sourceFaviconPath ?? undefined; + const canonicalFaviconPath = sourceFaviconPath + ? yield* ( + isExternalOverride + ? resolveCanonicalFile(sourceFaviconPath) + : resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: sourceFaviconPath }) + ).pipe( Effect.mapError( (cause) => new AssetProjectFaviconInspectionError({ @@ -316,27 +344,35 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ) : null; - if (relativePath && !canonicalFaviconPath) { + if (sourceFaviconPath && !canonicalFaviconPath) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); } - claims = { - version: 1, - kind: "project-favicon", - workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceResolutionError({ - resource: input.resource, - cause, - }), - ), - ), - relativePath, - expiresAt, - }; - if (relativePath && canonicalFaviconPath) { + claims = + isExternalOverride && canonicalFaviconPath + ? { + version: 1, + kind: "project-favicon-external", + filePath: canonicalFaviconPath, + expiresAt, + } + : { + version: 1, + kind: "project-favicon", + workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceResolutionError({ + resource: input.resource, + cause, + }), + ), + ), + relativePath, + expiresAt, + }; + if (sourceFaviconPath && canonicalFaviconPath) { const crypto = yield* Crypto.Crypto; const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( Effect.mapError( @@ -357,7 +393,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; + fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(sourceFaviconPath)}`; } else { fileName = PROJECT_FAVICON_FALLBACK_MARKER; } @@ -375,7 +411,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - if (claims.kind === "project-favicon") { + if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") { const issuedAt = yield* Clock.currentTimeMillis; expiresAt = (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * @@ -441,6 +477,21 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; } + if (claims.kind === "project-favicon-external") { + const faviconPath = yield* resolveCanonicalFile(claims.filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical asset path.", { + filePath: claims.filePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + return faviconPath === claims.filePath + ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) + : null; + } + const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts new file mode 100644 index 000000000000..cb08d5e4b2f1 --- /dev/null +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; +import { + ATTACHMENT_UPLOAD_ROUTE_PREFIX, + deletePendingAttachment, + issueAttachmentUploadUrl, + storeAttachmentUpload, + validateAttachmentUploadToken, +} from "./AttachmentUpload.ts"; + +const testLayer = ServerSecretStore.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-upload-" })), + Layer.provideMerge(NodeServices.layer), +); + +const uploadInput = { + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, +} as const; + +describe("AttachmentUpload", () => { + it.effect("signs the attachment metadata and validates the upload token", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + expect(parseThreadSegmentFromAttachmentId(issued.attachmentId)).toBe("pending"); + + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + expect(yield* validateAttachmentUploadToken(token)).toMatchObject({ + kind: "attachment-upload", + attachmentId: issued.attachmentId, + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects tampered and malformed upload tokens", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const [payload, signature] = token.split("."); + + expect(yield* validateAttachmentUploadToken(`${payload}x.${signature}`)).toBeNull(); + expect(yield* validateAttachmentUploadToken(`${token}.extra`)).toBeNull(); + expect(yield* validateAttachmentUploadToken("garbage")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects expired upload tokens", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + + yield* TestClock.adjust("11 minutes"); + expect(yield* validateAttachmentUploadToken(token)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes expired pending uploads while issuing a new upload URL", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const staleId = "pending-00000000-0000-4000-8000-0000000000cc"; + const stalePath = NodePath.join(config.attachmentsDir, `${staleId}.png`); + NodeFS.writeFileSync(stalePath, Buffer.from("pixels")); + NodeFS.utimesSync(stalePath, 0, 0); + + yield* TestClock.adjust("25 hours"); + yield* issueAttachmentUploadUrl(uploadInput); + + expect(NodeFS.existsSync(stalePath)).toBe(false); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("stores the expected bytes without leaving temporary files", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + expect(yield* storeAttachmentUpload(claims, new Uint8Array([1, 2, 3]))).toMatchObject({ + ok: false, + status: 400, + }); + expect(yield* storeAttachmentUpload(claims, new Uint8Array(6))).toEqual({ ok: true }); + expect( + NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.png`)), + ).toBe(true); + expect( + NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")), + ).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("deletes pending uploads without deleting thread-owned copies", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const uuid = "00000000-0000-4000-8000-0000000000dd"; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${uuid}.png`); + const claimedPath = NodePath.join(config.attachmentsDir, `thread-1-${uuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + NodeFS.writeFileSync(claimedPath, Buffer.from("pixels")); + + yield* deletePendingAttachment(`pending-${uuid}`); + yield* deletePendingAttachment(`pending-${uuid}`); + yield* deletePendingAttachment(`thread-1-${uuid}`); + + expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(claimedPath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts new file mode 100644 index 000000000000..6142b69d7342 --- /dev/null +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -0,0 +1,214 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { + ATTACHMENT_UPLOAD_URL_TTL_MS, + type AttachmentCreateUploadUrlInput, + AttachmentUploadSigningKeyError, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + createPendingAttachmentId, + parseThreadSegmentFromAttachmentId, + PENDING_ATTACHMENT_THREAD_SEGMENT, + resolveAttachmentPathById, + sweepStalePendingAttachments, +} from "../attachmentStore.ts"; +import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { inferImageExtension } from "../imageMime.ts"; + +export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload"; + +// Asset download tokens share this key, but their signed claim kind is different. +const SIGNING_SECRET_NAME = "asset-access-signing-key"; +const PENDING_ATTACHMENT_SWEEP_INTERVAL_MS = 15 * 60_000; +const lastPendingSweepByDirectory = new Map(); + +const AttachmentUploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment-upload"), + attachmentId: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + expiresAt: Schema.Number, +}); +export type AttachmentUploadClaims = typeof AttachmentUploadClaims.Type; + +const attachmentUploadClaimsJson = Schema.fromJsonString(AttachmentUploadClaims); +const decodeAttachmentUploadClaims = Schema.decodeUnknownOption(attachmentUploadClaimsJson); +const encodeAttachmentUploadClaims = Schema.encodeSync(attachmentUploadClaimsJson); + +function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null { + try { + return Option.getOrNull(decodeAttachmentUploadClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +const loadSigningSecret = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); +}); + +export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* ( + input: AttachmentCreateUploadUrlInput, +) { + const secret = yield* loadSigningSecret.pipe( + Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })), + ); + const config = yield* ServerConfig.ServerConfig; + const nowMs = yield* Clock.currentTimeMillis; + const previousSweep = lastPendingSweepByDirectory.get(config.attachmentsDir); + if ( + previousSweep === undefined || + nowMs - previousSweep >= PENDING_ATTACHMENT_SWEEP_INTERVAL_MS + ) { + lastPendingSweepByDirectory.set(config.attachmentsDir, nowMs); + const swept = sweepStalePendingAttachments({ + attachmentsDir: config.attachmentsDir, + nowMs, + }); + if (swept.deleted > 0) { + yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted }); + } + } + + const attachmentId = createPendingAttachmentId(); + const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS; + const encodedPayload = base64UrlEncode( + encodeAttachmentUploadClaims({ + version: 1, + kind: "attachment-upload", + attachmentId, + name: input.name, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + expiresAt, + }), + ); + + return { + attachmentId, + relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`, + expiresAt, + }; +}); + +export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* ( + token: string, +) { + const [encodedPayload, signature, unexpectedSegment] = token.split("."); + if (!encodedPayload || !signature || unexpectedSegment) { + return null; + } + + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the attachment upload signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) { + return null; + } + + const claims = decodeClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) { + return null; + } + return claims; +}); + +export type StoreAttachmentUploadResult = + | { readonly ok: true } + | { readonly ok: false; readonly status: number; readonly detail: string }; + +export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( + claims: AttachmentUploadClaims, + bytes: Uint8Array, +) { + if (bytes.byteLength !== claims.sizeBytes) { + return { + ok: false, + status: 400, + detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, + } satisfies StoreAttachmentUploadResult; + } + + const config = yield* ServerConfig.ServerConfig; + const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); + const relativePath = `${claims.attachmentId}${extension}`; + const finalPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath, + }); + const partPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${relativePath}.${NodeCrypto.randomUUID()}.part`, + }); + if (!finalPath || !partPath) { + return { ok: false, status: 500, detail: "Failed to resolve attachment path." }; + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true }); + yield* fileSystem.writeFile(partPath, bytes); + yield* fileSystem.rename(partPath, finalPath); + return { ok: true } satisfies StoreAttachmentUploadResult; + }).pipe( + Effect.catch((cause) => + fileSystem.remove(partPath, { force: true }).pipe( + Effect.orElseSucceed(() => undefined), + Effect.andThen( + Effect.logError("Failed to persist attachment upload.", { + attachmentId: claims.attachmentId, + cause, + }), + ), + Effect.as({ + ok: false, + status: 500, + detail: "Failed to persist upload.", + } satisfies StoreAttachmentUploadResult), + ), + ), + ); +}); + +export const deletePendingAttachment = Effect.fn("AttachmentUpload.deletePending")(function* ( + attachmentId: string, +) { + if (parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT) { + return; + } + + const config = yield* ServerConfig.ServerConfig; + const attachmentPath = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId, + }); + if (!attachmentPath) { + return; + } + + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(attachmentPath, { force: true }).pipe(Effect.orElseSucceed(() => {})); +}); diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e21d9cf62cf5..5e782e55407f 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -7,8 +7,12 @@ import { describe, expect, it } from "vite-plus/test"; import { createAttachmentId, + createPendingAttachmentId, + parseAttachmentUuid, + planAttachmentClaim, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, + sweepStalePendingAttachments, } from "./attachmentStore.ts"; describe("attachmentStore", () => { @@ -44,6 +48,16 @@ describe("attachmentStore", () => { expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo"); }); + it("reserves the pending attachment segment", () => { + const pendingId = createPendingAttachmentId(); + expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending"); + expect(parseAttachmentUuid(pendingId)).toMatch(/^[a-f0-9-]{36}$/); + expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending")!)).toBe("_pending"); + expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending_thread")!)).toBe( + "pending_thread", + ); + }); + it("resolves attachment path by id using the extension that exists on disk", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), @@ -77,4 +91,75 @@ describe("attachmentStore", () => { NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); } }); + + it("plans pending attachment claims with direct filename lookups", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"), + ); + try { + const uuid = "00000000-0000-4000-8000-000000000001"; + const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + + const claim = planAttachmentClaim({ + attachmentsDir, + threadId: "thread-1", + attachmentId: `pending-${uuid}`, + }); + expect(claim).toMatchObject({ + ok: true, + currentPath: pendingPath, + }); + if (!claim.ok) { + return; + } + expect(parseThreadSegmentFromAttachmentId(claim.finalId)).toBe("thread-1"); + expect(parseAttachmentUuid(claim.finalId)).not.toBe(uuid); + expect(claim.finalPath).toBe(NodePath.join(attachmentsDir, `${claim.finalId}.png`)); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("rejects thread-owned attachments even when thread segments collide", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-ownership-"), + ); + try { + const attachmentId = "a-b-00000000-0000-4000-8000-000000000003"; + NodeFS.writeFileSync(NodePath.join(attachmentsDir, `${attachmentId}.png`), "pixels"); + + expect(planAttachmentClaim({ attachmentsDir, threadId: "a b", attachmentId })).toEqual({ + ok: false, + reason: "attachment must be a pending upload", + }); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("removes expired pending and partial files without touching thread attachments", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-attachment-sweep-"), + ); + try { + const now = 1_800_000_000_000; + const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000; + const uuid = "00000000-0000-4000-8000-000000000002"; + const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); + const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`); + const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`); + for (const filePath of [pendingPath, threadPath, partialPath]) { + NodeFS.writeFileSync(filePath, Buffer.from("pixels")); + NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds); + } + + expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 }); + expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(partialPath)).toBe(false); + expect(NodeFS.existsSync(threadPath)).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db217..d0334bce09f3 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; @@ -19,6 +20,10 @@ const ATTACHMENT_ID_PATTERN = new RegExp( "i", ); +export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; +export const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; + export function toSafeThreadAttachmentSegment(threadId: string): string | null { const segment = threadId .trim() @@ -31,7 +36,19 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { if (segment.length === 0) { return null; } - return segment; + return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment; +} + +export function createPendingAttachmentId(): string { + return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`; +} + +export function parseAttachmentUuid(attachmentId: string): string | null { + const normalizedId = normalizeAttachmentRelativePath(attachmentId); + if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + return null; + } + return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null; } export function createAttachmentId(threadId: string): string | null { @@ -96,6 +113,105 @@ export function resolveAttachmentPathById(input: { return null; } +export type AttachmentClaimPlan = + | { + readonly ok: true; + readonly finalId: string; + readonly currentPath: string; + readonly finalPath: string; + } + | { readonly ok: false; readonly reason: string }; + +export function planAttachmentClaim(input: { + readonly attachmentsDir: string; + readonly threadId: string; + readonly attachmentId: string; +}): AttachmentClaimPlan { + const uuid = parseAttachmentUuid(input.attachmentId); + const requestedSegment = parseThreadSegmentFromAttachmentId(input.attachmentId); + if (!uuid || !requestedSegment) { + return { ok: false, reason: "invalid attachment id" }; + } + + if (!toSafeThreadAttachmentSegment(input.threadId)) { + return { ok: false, reason: "invalid thread id" }; + } + if (requestedSegment !== PENDING_ATTACHMENT_THREAD_SEGMENT) { + return { ok: false, reason: "attachment must be a pending upload" }; + } + + const currentPath = resolveAttachmentPathById({ + attachmentsDir: input.attachmentsDir, + attachmentId: input.attachmentId, + }); + if (!currentPath) { + return { ok: false, reason: "attachment not found (removed or expired)" }; + } + const finalId = createAttachmentId(input.threadId); + if (!finalId) { + return { ok: false, reason: "failed to create attachment id" }; + } + + const expectedFinalPath = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: `${finalId}${NodePath.extname(currentPath)}`, + }); + if (!expectedFinalPath) { + return { ok: false, reason: "failed to resolve attachment path" }; + } + return { + ok: true, + finalId, + currentPath, + finalPath: expectedFinalPath, + }; +} + +export function sweepStalePendingAttachments(input: { + readonly attachmentsDir: string; + readonly nowMs: number; +}): { readonly deleted: number } { + let entries: string[]; + try { + entries = NodeFS.readdirSync(input.attachmentsDir); + } catch { + return { deleted: 0 }; + } + + let deleted = 0; + for (const entry of entries) { + const isPartial = entry.endsWith(".part"); + if (!isPartial) { + const attachmentId = parseAttachmentIdFromRelativePath(entry); + if ( + !attachmentId || + parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT + ) { + continue; + } + } + + const resolved = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: entry, + }); + if (!resolved) { + continue; + } + try { + const maxAgeMs = isPartial ? PARTIAL_UPLOAD_MAX_AGE_MS : PENDING_ATTACHMENT_MAX_AGE_MS; + if (input.nowMs - NodeFS.statSync(resolved).mtimeMs > maxAgeMs) { + NodeFS.unlinkSync(resolved); + deleted += 1; + } + } catch { + continue; + } + } + + return { deleted }; +} + export function parseAttachmentIdFromRelativePath(relativePath: string): string | null { const normalized = normalizeAttachmentRelativePath(relativePath); if (!normalized || normalized.includes("/")) { diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 790be9386e6e..25971b0c0aec 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -37,6 +37,12 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); }); + it("requires permission to operate on a thread before uploading feedback", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.providerUploadFeedback)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..28ceac4cec99 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -84,6 +84,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, + [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, + [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, + [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 334c24ef52fd..1fb01c1f0002 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; @@ -47,6 +48,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi revoke: () => Effect.fail(repositoryFailure), revokeAllExcept: () => Effect.fail(repositoryFailure), setLastConnectedAt: () => Effect.void, + setClientConnection: () => Effect.void, }); const failingSessionLookupCredentialLayer = Layer.effect( @@ -315,4 +317,35 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); + it.effect("records client connection metadata without clearing prior values", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const issued = yield* sessions.issue({ + subject: "client-connection-test", + method: "bearer-access-token", + }); + const readRow = sql<{ + readonly surface: string | null; + readonly appVersion: string | null; + }>` + SELECT client_surface AS "surface", client_app_version AS "appVersion" + FROM auth_sessions + WHERE session_id = ${issued.sessionId} + `; + + yield* sessions.recordClientConnection(issued.sessionId, { + surface: "mobile", + appVersion: "1.2.0", + }); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.2.0" }); + + // A partial report (old or minimal client) must not null out stored data. + yield* sessions.recordClientConnection(issued.sessionId, { appVersion: "1.3.0" }); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); + + yield* sessions.recordClientConnection(issued.sessionId, {}); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); + }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), + ); }); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 40a1c43e0be7..cdcd4a1ac198 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -5,6 +5,7 @@ import { type AuthClientMetadata, type AuthClientSession, type AuthEnvironmentScope, + type ClientSurface, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -396,6 +397,13 @@ export class SessionStore extends Context.Service< ) => Effect.Effect; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly recordClientConnection: ( + sessionId: AuthSessionId, + client: { + readonly surface?: ClientSurface | undefined; + readonly appVersion?: string | undefined; + }, + ) => Effect.Effect; } >()("t3/auth/SessionStore") {} @@ -544,6 +552,28 @@ export const make = Effect.gen(function* () { Effect.withSpan("SessionStore.markConnected"), ); + // Best-effort: connection metadata must never block or fail a connect. + const recordClientConnection: SessionStore["Service"]["recordClientConnection"] = ( + sessionId, + client, + ) => + client.surface === undefined && client.appVersion === undefined + ? Effect.void + : authSessions + .setClientConnection({ + sessionId, + surface: client.surface ?? null, + appVersion: client.appVersion ?? null, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to record session client connection metadata.").pipe( + Effect.annotateLogs({ sessionId, cause }), + ), + ), + Effect.withSpan("SessionStore.recordClientConnection"), + ); + const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -912,6 +942,7 @@ export const make = Effect.gen(function* () { revokeAllExcept, markConnected, markDisconnected, + recordClientConnection, }); }); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 3370a2299dca..8d2ee75acf2e 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -12,6 +12,7 @@ import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; +import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; @@ -63,7 +64,13 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => export const cli = makeCli(); -if (import.meta.main) { +if ( + isEntrypoint({ + moduleUrl: import.meta.url, + entryPath: process.argv[1], + runtimeMain: import.meta.main, + }) +) { Command.run(cli, { version: packageJson.version }).pipe( Effect.scoped, Effect.provide(CliRuntimeLayer), diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 1314ccfb9361..a999f81b2898 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -56,17 +56,26 @@ const macPlan = { logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", }; +const macInstallerPath = + "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; +const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain("/opt/homebrew/bin/node"); expect(plist).toContain("/Users/theo/.t3/runtime/service-launcher.mjs"); expect(plist).not.toContain("versions/1.2.3"); }); +it("preserves the installer's provider search path in the launch agent", () => { + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); + + expect(plist).toContain(` PATH\n ${macInstallerPath}`); +}); + it("restarts the launch agent on the systemd cadence", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain("RunAtLoad\n "); expect(plist).toContain("KeepAlive\n "); @@ -75,7 +84,7 @@ it("restarts the launch agent on the systemd cadence", () => { }); it("appends both stdio streams to the boot service log", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain( "StandardOutPath\n /Users/theo/.t3/userdata/logs/boot-service.log", @@ -88,15 +97,17 @@ it("appends both stdio streams to the boot service log", () => { it("escapes XML in host paths", () => { const plist = BootService.renderBootServicePlist( { ...macPlan, baseDir: "/Users/theo/T3 & " }, - { homeDir: "/Users/theo" }, + { homeDir: "/Users/theo", environmentPath: "/Users/theo/Tools & :/usr/bin" }, ); expect(plist).toContain("/Users/theo/T3 & <Co>"); + expect(plist).toContain("/Users/theo/Tools & <Scripts>:/usr/bin"); }); const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", usePinnedLauncher = false, + installerPath = macInstallerPath, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -135,27 +146,33 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( }; }), }); - const service = yield* BootService.make({ - baseDir, - logsDir: path.join(baseDir, "userdata", "logs"), - cliVersion: "1.2.3", - host: { - execPath: "/usr/bin/node", - ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), - }, - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, runner), - Effect.provide( - Layer.mergeAll( - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessUserId, 501), - Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), - Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), - ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), + const makeService = (environmentPath = installerPath) => + BootService.make({ + baseDir, + logsDir: path.join(baseDir, "userdata", "logs"), + cliVersion: "1.2.3", + host: { + execPath: "/usr/bin/node", + ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), + }, + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessUserId, 501), + Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), + Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, + }), + ), + ), ), - ), - ); - return { service, fs, statePath, commands, timeouts, control }; + ); + const service = yield* makeService(); + return { service, makeService, fs, statePath, commands, timeouts, control }; }); it.layer(NodeServices.layer)("boot service install", (it) => { @@ -266,6 +283,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe( true, ); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + ` PATH\n ${macInstallerPath}:/usr/local/bin:/usr/sbin:/sbin`, + ); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", @@ -303,6 +323,58 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("reconstructs a launch agent search path when the installer has no PATH", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness("darwin", false, ""); + const plan = yield* service.install; + + expect(yield* fs.readFileString(plan.unitPath)).toContain( + " PATH\n /usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", + ); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("adds missing provider directories to a minimal installer PATH", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); + const plan = yield* service.install; + + expect(yield* fs.readFileString(plan.unitPath)).toContain( + " PATH\n /usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin", + ); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("keeps an installed launch agent current when the process PATH changes", () => + Effect.gen(function* () { + const { service, makeService } = yield* makeHarness("darwin"); + yield* service.install; + + const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin"); + expect((yield* restartedService.status).current).toBe(true); + }), + ); + + it.effect("drops PATH directories that cannot be represented in a launch agent plist", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness( + "darwin", + false, + "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", + ); + const plan = yield* service.install; + const plist = yield* fs.readFileString(plan.unitPath); + + expect(plist).toContain( + " PATH\n /opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", + ); + expect(plist).not.toContain("\u0001"); + expect((yield* service.status).current).toBe(true); + }), + ); + it.effect("ignores a bootout for an agent that is not loaded", () => Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 795bf38e979d..6b7e13d0bbba 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -99,7 +99,7 @@ export function escapeXmlText(value: string): string { /** Pure renderer: launch agents cannot rely on the user's shell or PATH. */ export function renderBootServicePlist( plan: BootServicePlan, - options: { readonly homeDir: string }, + options: { readonly homeDir: string; readonly environmentPath: string }, ): string { // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever. @@ -127,6 +127,8 @@ export function renderBootServicePlist( ` `, ` EnvironmentVariables`, ` `, + ` PATH`, + ` ${escapeXmlText(options.environmentPath)}`, ` T3CODE_HOME`, ` ${escapeXmlText(plan.baseDir)}`, ` ${BOOT_SERVICE_UNIT_ENV}`, @@ -268,6 +270,7 @@ export function launchdManager(input: { readonly path: Path.Path; readonly homeDir: string; readonly uid: number; + readonly environmentPath: string; }): BootServiceManager { const unitPath = input.path.join( input.homeDir, @@ -287,7 +290,11 @@ export function launchdManager(input: { return { kind: "launchd", unitPath, - render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir }), + render: (plan) => + renderBootServicePlist(plan, { + homeDir: input.homeDir, + environmentPath: input.environmentPath, + }), // Without --wait, bootout returns in milliseconds while the job drains // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. // --wait (present on modern macOS, absent from the man page) blocks until @@ -346,6 +353,7 @@ export function selectBootServiceManager(input: { readonly homeDir: string; readonly uid: number | undefined; readonly path: Path.Path; + readonly environmentPath: string; }): BootServiceManager | undefined { if (input.homeDir === "") { return undefined; @@ -354,7 +362,12 @@ export function selectBootServiceManager(input: { return systemdManager({ path: input.path, homeDir: input.homeDir }); } if (input.platform === "darwin" && input.uid !== undefined) { - return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid }); + return launchdManager({ + path: input.path, + homeDir: input.homeDir, + uid: input.uid, + environmentPath: input.environmentPath, + }); } return undefined; } @@ -441,12 +454,39 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const platform = yield* HostProcessPlatform; const uid = yield* HostProcessUserId; const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); + const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const host = input.host ?? { execPath: hostExecPath }; - - const detectedManager = selectBootServiceManager({ platform, homeDir, uid, path }); + const xmlSafeInstallerDirectories = installerPath.split(":").filter( + (directory) => + directory.length > 0 && + Array.from(directory).every((character) => { + const code = character.charCodeAt(0); + return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; + }), + ); + const environmentPath = Array.from( + new Set([ + ...xmlSafeInstallerDirectories, + path.dirname(host.execPath), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]), + ).join(":"); + + const detectedManager = selectBootServiceManager({ + platform, + homeDir, + uid, + path, + environmentPath, + }); const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); @@ -664,11 +704,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { fs.readFileString(statePath).pipe(Effect.option), ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; + const normalizeUnit = (contents: string) => + detectedManager.kind === "launchd" + ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") + : contents; return { supported: true, installed: true, current: - unit === detectedManager.render(plan) && + normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && launcherExists && runtimeEntryExists && Option.isSome(runtimeSentinel) && diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e678264dde5f..bdff19572fdd 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -7,6 +7,7 @@ * @module ServerConfig */ import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -14,6 +15,8 @@ import * as LogLevel from "effect/LogLevel"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { sweepStalePendingAttachments } from "./attachmentStore.ts"; + export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); @@ -152,6 +155,14 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server ], { concurrency: "unbounded" }, ); + + const swept = sweepStalePendingAttachments({ + attachmentsDir: derivedPaths.attachmentsDir, + nowMs: yield* Clock.currentTimeMillis, + }); + if (swept.deleted > 0) { + yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted }); + } }); const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( diff --git a/apps/server/src/entrypoint.test.ts b/apps/server/src/entrypoint.test.ts new file mode 100644 index 000000000000..56f2c119764a --- /dev/null +++ b/apps/server/src/entrypoint.test.ts @@ -0,0 +1,89 @@ +// @effect-diagnostics nodeBuiltinImport:off - entrypoint detection is a Node filesystem boundary. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { describe, expect, it } from "vite-plus/test"; + +import { isEntrypoint } from "./entrypoint.ts"; + +const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-")); + +describe("isEntrypoint", () => { + it("uses the runtime answer when Node provides one", () => { + // Node 22.18+ and 24.2+ populate `import.meta.main`; nothing else is consulted. + expect( + isEntrypoint({ + moduleUrl: "file:///somewhere/bin.mjs", + entryPath: "/elsewhere/other.mjs", + runtimeMain: true, + }), + ).toBe(true); + expect( + isEntrypoint({ + moduleUrl: "file:///somewhere/bin.mjs", + entryPath: "/somewhere/bin.mjs", + runtimeMain: false, + }), + ).toBe(false); + }); + + it("matches the entrypoint path when the runtime has no import.meta.main", () => { + // Node 22.16, 22.17 and 23.11 are inside `engines.node` but leave it undefined. + const dir = makeTempDir(); + const entry = NodePath.join(dir, "bin.mjs"); + NodeFS.writeFileSync(entry, ""); + + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(entry).href, + entryPath: entry, + runtimeMain: undefined, + }), + ).toBe(true); + }); + + it("matches through a symlinked entrypoint, as npm and npx install it", () => { + const dir = makeTempDir(); + const real = NodePath.join(dir, "bin.mjs"); + const link = NodePath.join(dir, "t3"); + NodeFS.writeFileSync(real, ""); + NodeFS.symlinkSync(real, link); + + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(real).href, + entryPath: link, + runtimeMain: undefined, + }), + ).toBe(true); + }); + + it("stays false for an imported module that is not the entrypoint", () => { + // This is what keeps `bin.test.ts` from launching the CLI on import. + const dir = makeTempDir(); + const entry = NodePath.join(dir, "bin.mjs"); + const imported = NodePath.join(dir, "cli.mjs"); + NodeFS.writeFileSync(entry, ""); + NodeFS.writeFileSync(imported, ""); + + expect( + isEntrypoint({ + moduleUrl: NodeURL.pathToFileURL(imported).href, + entryPath: entry, + runtimeMain: undefined, + }), + ).toBe(false); + }); + + it("stays false when there is no entrypoint argument", () => { + expect( + isEntrypoint({ + moduleUrl: "file:///somewhere/bin.mjs", + entryPath: undefined, + runtimeMain: undefined, + }), + ).toBe(false); + }); +}); diff --git a/apps/server/src/entrypoint.ts b/apps/server/src/entrypoint.ts new file mode 100644 index 000000000000..1ac083ec5873 --- /dev/null +++ b/apps/server/src/entrypoint.ts @@ -0,0 +1,38 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Entrypoint detection runs before any Effect runtime is built, so it stays on +// Node built-ins. +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; + +/** + * Whether the module identified by `moduleUrl` is the process entrypoint. + * + * `import.meta.main` answers this directly, but it only exists on Node 22.18+ + * and 24.2+. This package's `engines.node` range also accepts 22.16, 22.17 and + * 23.11, where it is `undefined`: an `if (import.meta.main)` guard never runs, + * so the process loads every module and exits 0 without output. Fall back to + * comparing the entrypoint path on those versions. + */ +export const isEntrypoint = (input: { + readonly moduleUrl: string; + readonly entryPath: string | undefined; + readonly runtimeMain: boolean | undefined; +}): boolean => { + if (input.runtimeMain !== undefined) { + return input.runtimeMain; + } + if (input.entryPath === undefined || input.entryPath === "") { + return false; + } + if (input.moduleUrl === NodeURL.pathToFileURL(input.entryPath).href) { + return true; + } + // npm and npx install the CLI as a symlink. Without `--preserve-symlinks` the + // module URL is the resolved real path while `process.argv[1]` keeps the link + // path, so the comparison above misses. + try { + return input.moduleUrl === NodeURL.pathToFileURL(NodeFS.realpathSync(input.entryPath)).href; + } catch { + return false; + } +}; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 44639fc7f8d4..0e2bd512bc8e 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -90,9 +90,11 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadTurnRetraction).toBe(true); + expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1201788f25fb..ba4a2681af24 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -146,6 +146,7 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + attachmentUploads: true, pullRequests: true, threadSettlement: true, threadSnooze: true, @@ -153,6 +154,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadTitleRegeneration: true, threadTurnRetraction: true, + threadPullRequestLinking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6291b3f33b2f..9e2cf15ecb72 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -974,6 +974,75 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status finds a merged PR after its remote branch was deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/merged-branch-deleted"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/merged-branch-deleted"]); + + // GitHub commonly deletes a pull request's head branch after merge. Git + // removes the remote-tracking ref, but preserves the local branch's + // remote and merge configuration as evidence that it was published. + yield* runGit(repoDir, ["push", "origin", "--delete", "feature/merged-branch-deleted"]); + const configuredRemote = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.remote", + ]); + const configuredMerge = yield* runGit(repoDir, [ + "config", + "--get", + "branch.feature/merged-branch-deleted.merge", + ]); + const trackingRef = yield* runGit(repoDir, [ + "for-each-ref", + "--format=%(refname)", + "refs/remotes/origin/feature/merged-branch-deleted", + ]); + expect(configuredRemote.stdout.trim()).toBe("origin"); + expect(configuredMerge.stdout.trim()).toBe("refs/heads/feature/merged-branch-deleted"); + expect(trackingRef.stdout.trim()).toBe(""); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 215, + title: "Merged branch was deleted", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRefName: "main", + headRefName: "feature/merged-branch-deleted", + state: "MERGED", + mergedAt: "2026-04-02T15:00:00Z", + updatedAt: "2026-04-02T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.hasUpstream).toBe(false); + expect(status.pr).toEqual({ + number: 215, + title: "Merged branch was deleted", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRef: "main", + headRef: "feature/merged-branch-deleted", + state: "merged", + updatedAt: "2026-04-02T15:00:00.000Z", + }); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); + }), + ); + it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2404,6 +2473,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("create_pr targets the remote default branch when it is not main", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + // A repository whose default branch is master, with no main anywhere. + yield* runGit(repoDir, ["push", "origin", "HEAD:master"]); + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "master"]); + + yield* runGit(repoDir, ["checkout", "-b", "feature/master-default"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "master-default.txt"), "master default\n"); + yield* runGit(repoDir, ["add", "master-default.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Master default"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + // Mirrors a provider that cannot report a default branch, as the Azure + // DevOps CLI does when it cannot detect the repository. + defaultBranch: "", + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 505, + title: "Master default", + url: "https://github.com/pingdotgg/codething-mvp/pull/505", + baseRefName: "master", + headRefName: "feature/master-default", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect( + ghCalls.some((call) => + call.includes("pr create --base master --head feature/master-default"), + ), + ).toBe(true); + }), + ); + it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c135051260fe..5ea4a0072d66 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1268,15 +1268,17 @@ export const make = Effect.gen(function* () { * cannot exist for it and asking the provider is a guaranteed-empty API call. * * `git push` writes the remote-tracking ref even without `-u` (how most - * terminal and agent pushes land), which makes this a safer "did it ever - * reach the host" test than looking for upstream config, and the glob spans - * every remote so a fork branch still counts. A repository that tracks no - * remotes at all cannot answer the question, because then every branch looks - * unpublished; it, and any failed probe, keeps the lookup. + * terminal and agent pushes land), and configured upstream metadata survives + * when a merged change request's remote branch is deleted. Together they + * distinguish branches known to have reached a host from genuinely local + * branches. The ref glob spans every remote so a fork branch still counts. A + * repository that tracks no remotes at all cannot answer the question, + * because then every branch looks unpublished; it, and any failed probe, + * keeps the lookup. */ const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( cwd: string, - headContext: Pick, + headContext: Pick, ) { if (headContext.headBranch.length === 0) { return false; @@ -1291,13 +1293,24 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.map((result) => result.stdout.trim().length > 0)); - return yield* Effect.all( - [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], - { concurrency: "unbounded" }, - ).pipe( - Effect.map(([tracksAnyRemote, tracksThisBranch]) => tracksAnyRemote && !tracksThisBranch), - Effect.orElseSucceed(() => false), - ); + return yield* Effect.gen(function* () { + const [configuredRemote, configuredMerge] = yield* Effect.all( + [ + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.remote`), + gitCore.readConfigValue(cwd, `branch.${headContext.localBranch}.merge`), + ], + { concurrency: "unbounded" }, + ); + if (configuredRemote !== null && configuredMerge !== null) { + return false; + } + + const [tracksAnyRemote, tracksThisBranch] = yield* Effect.all( + [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)], + { concurrency: "unbounded" }, + ); + return tracksAnyRemote && !tracksThisBranch; + }).pipe(Effect.orElseSucceed(() => false)); }); const findOpenPr = Effect.fn("findOpenPr")(function* ( @@ -1489,6 +1502,18 @@ export const make = Effect.gen(function* () { return defaultFromProvider; } + // The provider lookup can fail for reasons unrelated to the branch, so fall + // back to what the remote itself records before assuming a name. A repository + // whose default branch is master would otherwise get a base branch that does + // not exist. + const defaultFromRemote = yield* gitCore.resolvePrimaryRemoteName(cwd).pipe( + Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)), + Effect.orElseSucceed(() => null), + ); + if (defaultFromRemote) { + return defaultFromRemote; + } + return "main"; }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index da22794951fb..a73aa59d5516 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -84,6 +84,9 @@ export class GitWorkflowService extends Context.Service< readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; + readonly pruneWorktrees: (input: { + readonly cwd: string; + }) => Effect.Effect; readonly createRef: ( input: VcsCreateRefInput, ) => Effect.Effect; @@ -319,6 +322,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), ), + pruneWorktrees: (input) => + ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( + Effect.andThen(git.pruneWorktrees(input)), + ), createRef: (input) => ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( Effect.andThen(git.createRef(input)), diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index d2937a97f088..2c3b0c44fe97 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -47,4 +47,15 @@ describe("assetResponseHeaders", () => { "X-Content-Type-Options": "nosniff", }); }); + + it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => { + expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty( + "Content-Type", + "text/html; charset=utf-8", + ); + expect(assetResponseHeaders("/workspace/PAGE.HTM")).toHaveProperty( + "Content-Type", + "text/html; charset=utf-8", + ); + }); }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 214134b51b51..a44dce70e288 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -28,6 +28,11 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { + ATTACHMENT_UPLOAD_ROUTE_PREFIX, + storeAttachmentUpload, + validateAttachmentUploadToken, +} from "./assets/AttachmentUpload.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -46,10 +51,14 @@ const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; export function assetResponseHeaders(filePath: string): Record { + const lowerPath = filePath.toLowerCase(); return { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", - ...(filePath.toLowerCase().endsWith(".svg") + ...(lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") + ? { "Content-Type": "text/html; charset=utf-8" } + : {}), + ...(lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}), }; @@ -226,6 +235,51 @@ export const assetRouteLayer = HttpRouter.add( }), ); +export const attachmentUploadRouteLayer = HttpRouter.add( + "POST", + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + if (!token) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const contentLengthHeader = request.headers["content-length"]; + if ( + contentLengthHeader !== undefined && + (!Number.isInteger(Number(contentLengthHeader)) || + Number(contentLengthHeader) !== claims.sizeBytes) + ) { + return HttpServerResponse.text("Content-Length must match the upload size.", { + status: 400, + }); + } + + const body = yield* request.arrayBuffer.pipe( + Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)), + Effect.orElseSucceed(() => null), + ); + if (body === null) { + return HttpServerResponse.text("Failed to read the upload body.", { status: 400 }); + } + + const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body)); + return stored.ok + ? HttpServerResponse.empty({ status: 204 }) + : HttpServerResponse.text(stored.detail, { status: stored.status }); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 24a137d933fa..1f05604934e4 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 594f7513dc6e..683602e0fda2 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -20,7 +20,7 @@ function activity(payload: Record): OrchestrationThreadActivity * If slimming ever moves to an allowlist over the whole payload, these * assertions are the tripwire. */ -describe("projectActivityPayload agent-field survival", () => { +describe("projectActivityPayload", () => { it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ @@ -115,7 +115,47 @@ describe("projectActivityPayload agent-field survival", () => { expect(data.resultTruncated).toBe(true); }); - it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { + it("normalizes Claude and OpenCode command inputs while retaining provider output", () => { + const claude = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "claude-call-1", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + result: { content: "x".repeat(5_000) }, + }, + }), + ); + const openCode = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "opencode-call-1", + data: { + tool: "bash", + state: { + status: "running", + input: { command: "vp lint" }, + output: "x".repeat(5_000), + }, + }, + }), + ); + + // Output stays intact under the projection cap so expanded work-log rows + // can show it; only the command is normalized to a top-level field. + expect(claude.payload).toMatchObject({ + toolCallId: "claude-call-1", + data: { command: "vp test run", result: { content: "x".repeat(5_000) } }, + }); + expect(openCode.payload).toMatchObject({ + toolCallId: "opencode-call-1", + data: { command: "vp lint", state: { status: "running", output: "x".repeat(5_000) } }, + }); + expect((claude.payload as Record).data).not.toHaveProperty("resultTruncated"); + }); + + it("slims Codex-shaped mcp_tool_call items to rendered fields while retaining the result", () => { const projected = projectActivityPayload( activity({ itemType: "mcp_tool_call", @@ -143,11 +183,14 @@ describe("projectActivityPayload agent-field survival", () => { expect(item.server).toBe("github"); expect(item.arguments).toEqual({ pr: 42 }); expect(item._meta).toBeUndefined(); - expect(item.result).toEqual({ content: "PR body line one" }); - expect(JSON.stringify(projected.payload).length).toBeLessThan(500); + expect(item.result).toEqual({ + content: [{ type: "text", text: `PR body line one\n${"x".repeat(5000)}` }], + structuredContent: { huge: "y".repeat(5000) }, + }); + expect(data.resultTruncated).toBeUndefined(); }); - it("slims Claude-shaped mcp_tool_call data (toolName/input/result block)", () => { + it("keeps Claude-shaped mcp_tool_call data (toolName/input/result block) intact", () => { const projected = projectActivityPayload( activity({ itemType: "mcp_tool_call", @@ -165,8 +208,12 @@ describe("projectActivityPayload agent-field survival", () => { const data = (projected.payload as Record).data as Record; expect(data.toolName).toBe("mcp__github__fetch_pr"); expect(data.input).toEqual({ pr: 42 }); - expect(data.result).toEqual({ content: "first line of output" }); - expect(JSON.stringify(projected.payload).length).toBeLessThan(500); + expect(data.result).toEqual({ + type: "tool_result", + tool_use_id: "toolu_1", + content: [{ type: "text", text: `first line of output\n${"z".repeat(5000)}` }], + }); + expect(data.resultTruncated).toBeUndefined(); }); it("passes task lifecycle payloads (no data field) through untouched", () => { diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 17dd7325633d..8306324bc331 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -299,6 +299,24 @@ function projectCommandData(data: Record): { }; } +function projectCommandValue(data: Record): unknown { + if (data.command !== undefined) { + return data.command; + } + + const input = asRecord(data.input); + if (input?.command !== undefined) { + return input.command; + } + + const stateInput = asRecord(asRecord(data.state)?.input); + if (stateInput?.command !== undefined) { + return stateInput.command; + } + + return undefined; +} + /** * Fields of an MCP tool-call item clients use for identity and presentation. * Result content is retained separately under the tool-output cap. @@ -384,11 +402,17 @@ export function projectActivityPayload( return normalizedActivity; } + const itemStatus = asRecord(data.item)?.status; + const projectedPayload = + payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") + ? { ...payload, status: itemStatus } + : payload; + if (payload.itemType === "mcp_tool_call") { return { ...normalizedActivity, payload: { - ...payload, + ...projectedPayload, data: projectMcpToolCallData(data), }, }; @@ -409,6 +433,10 @@ export function projectActivityPayload( : ["toolName", "input", "result", "rawOutput", "state"], copyKeys: ["command", "tool", "toolCallId", "kind"], }); + const command = projectCommandValue(data); + if (command !== undefined) { + projectedData.command = command; + } const changedFiles: string[] = []; collectChangedFiles(data, changedFiles, new Set(), 0); @@ -433,7 +461,7 @@ export function projectActivityPayload( return { ...normalizedActivity, payload: { - ...payload, + ...projectedPayload, data: projectedData, }, }; @@ -486,12 +514,10 @@ function dropStaleContextWindowActivities( } /** - * Identity both clients use to fold a tool lifecycle row into the call it - * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and - * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter - * emits one, otherwise the itemType/title/detail triple. Returns null for rows - * with no identity at all — those never collapse on the client either, so they - * must not be dropped here. + * Identity used to retain only the newest lifecycle row for each call in a + * thread snapshot. Prefer the runtime item id, then the legacy nested id, and + * finally the itemType/title/detail triple. Rows without any identity remain + * untouched. */ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { const payload = asRecord(activity.payload); @@ -499,7 +525,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | return null; } - const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId); + const toolCallId = + asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); if (toolCallId) { return `id:${toolCallId}`; } diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 1b9518da9f34..663135aed158 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -127,6 +127,7 @@ function createProviderServiceHarness( }), rollbackConversation, rollbackConversationTo: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 49f9a3c7a1cf..e8cc59ca7bc5 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1566,4 +1566,55 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + + it("stamps the dispatching client's origin onto persisted event metadata", async () => { + const createdAt = now(); + const system = await createOrchestrationSystem(); + const { engine } = system; + + await system.run( + engine.dispatch( + { + type: "project.create", + commandId: CommandId.make("cmd-origin-project-create"), + projectId: asProjectId("project-origin"), + title: "Origin Project", + workspaceRoot: "/tmp/project-origin", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }, + { origin: { surface: "mobile", appVersion: "1.2.3" } }, + ), + ); + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-no-origin-project-create"), + projectId: asProjectId("project-no-origin"), + title: "No Origin Project", + workspaceRoot: "/tmp/project-no-origin", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + + const events = await system.run( + Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), + ); + const withOrigin = events.find((event) => event.commandId === "cmd-origin-project-create"); + const withoutOrigin = events.find( + (event) => event.commandId === "cmd-no-origin-project-create", + ); + + expect(withOrigin?.metadata.origin).toEqual({ surface: "mobile", appVersion: "1.2.3" }); + expect(withoutOrigin?.metadata.origin).toBeUndefined(); + + await system.dispose(); + }); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 8c4c4aab8b30..0af748c7cd5b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,4 +1,5 @@ import type { + OrchestrationClientOrigin, OrchestrationEvent, OrchestrationReadModel, ProjectId, @@ -54,6 +55,7 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar interface CommandEnvelope { command: OrchestrationCommand; + origin: OrchestrationClientOrigin | undefined; result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; startedAtMs: number; } @@ -187,7 +189,16 @@ const makeOrchestrationEngine = Effect.gen(function* () { }), ), ); - const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; + const plannedEvents = Array.isArray(eventBase) ? eventBase : [eventBase]; + // Stamp the dispatching client's origin onto every event the command + // produced. The decider stays pure; attribution is an engine concern. + const eventBases = + envelope.origin === undefined + ? plannedEvents + : plannedEvents.map((planned) => ({ + ...planned, + metadata: { ...planned.metadata, origin: envelope.origin }, + })); const committedCommand = yield* sql .withTransaction( Effect.gen(function* () { @@ -334,11 +345,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => eventStore.readFromSequence(fromSequenceExclusive, limit); - const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) => Effect.gen(function* () { const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); yield* Queue.offer(commandQueue, { command, + origin: options?.origin, result, startedAtMs: yield* Clock.currentTimeMillis, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index ea3f11aa83e0..7edfd907cd4f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -177,6 +177,78 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { assert.equal(row.lastAppliedSequence, 3); } + yield* sql`CREATE TABLE thread_shell_updates (count INTEGER NOT NULL)`; + yield* sql`INSERT INTO thread_shell_updates (count) VALUES (0)`; + yield* sql` + CREATE TRIGGER count_thread_shell_updates + AFTER UPDATE ON projection_threads + WHEN NEW.thread_id = 'thread-1' + BEGIN + UPDATE thread_shell_updates SET count = count + 1; + END; + `; + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-assistant-update"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.100Z", + commandId: CommandId.make("cmd-assistant-update"), + causationEventId: null, + correlationId: CommandId.make("cmd-assistant-update"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-2"), + role: "assistant", + text: "more work", + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.100Z", + updatedAt: "2026-01-01T00:00:00.100Z", + }, + }); + yield* projectionPipeline.bootstrap; + + let threadShellUpdates = yield* sql<{ readonly count: number }>` + SELECT count FROM thread_shell_updates + `; + assert.deepEqual(threadShellUpdates, [{ count: 1 }]); + + yield* sql`UPDATE thread_shell_updates SET count = 0`; + yield* eventStore.append({ + type: "thread.activity-appended", + eventId: EventId.make("evt-routine-activity"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.200Z", + commandId: CommandId.make("cmd-routine-activity"), + causationEventId: null, + correlationId: CommandId.make("cmd-routine-activity"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-routine"), + tone: "tool", + kind: "tool.updated", + summary: "Tool made progress", + payload: {}, + turnId: null, + createdAt: "2026-01-01T00:00:00.200Z", + }, + }, + }); + yield* projectionPipeline.bootstrap; + + threadShellUpdates = yield* sql<{ readonly count: number }>` + SELECT count FROM thread_shell_updates + `; + assert.deepEqual(threadShellUpdates, [{ count: 1 }]); + yield* sql`DROP TRIGGER count_thread_shell_updates`; + yield* sql`DROP TABLE thread_shell_updates`; + // Settled lifecycle through the DB pipeline: thread.settled writes the // override + timestamp, thread.unsettled(user) flips to the active pin. yield* eventStore.append({ @@ -200,15 +272,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const settledRows = yield* sql<{ readonly settledOverride: string | null; readonly settledAt: string | null; + readonly unsettledAt: string | null; }>` SELECT settled_override AS "settledOverride", - settled_at AS "settledAt" + settled_at AS "settledAt", + unsettled_at AS "unsettledAt" FROM projection_threads WHERE thread_id = 'thread-1' `; assert.deepEqual(settledRows, [ - { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" }, + { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null }, ]); yield* eventStore.append({ @@ -232,14 +306,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const unsettledRows = yield* sql<{ readonly settledOverride: string | null; readonly settledAt: string | null; + readonly unsettledAt: string | null; }>` SELECT settled_override AS "settledOverride", - settled_at AS "settledAt" + settled_at AS "settledAt", + unsettled_at AS "unsettledAt" FROM projection_threads WHERE thread_id = 'thread-1' `; - assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); + // The un-settle stamps the active-list re-entry time so clients can + // surface the thread at the top of the list. + assert.deepEqual(unsettledRows, [ + { + settledOverride: "active", + settledAt: null, + unsettledAt: "2026-01-01T00:00:02.000Z", + }, + ]); }), ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 13a380d1734d..bb3e6e3e318c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -141,6 +141,29 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } +// A full refresh loads all thread history, so skip events that cannot change the summary. +function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { + if (event.type === "thread.message-sent") { + return event.payload.role === "user"; + } + + if (event.type !== "thread.activity-appended") { + return true; + } + + switch (event.payload.activity.kind) { + case "approval.requested": + case "approval.resolved": + case "provider.approval.respond.failed": + case "user-input.requested": + case "user-input.resolved": + case "provider.user-input.respond.failed": + return true; + default: + return false; + } +} + function derivePendingUserInputCountFromActivities( activities: ReadonlyArray, ): number { @@ -566,12 +589,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti branch: event.payload.branch, worktreePath: event.payload.worktreePath, managedWorktree: null, + linkedPullRequest: null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -629,6 +654,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, settledOverride: "settled", settledAt: event.payload.settledAt, + unsettledAt: null, updatedAt: event.payload.updatedAt, }); return; @@ -645,6 +671,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, settledOverride: event.payload.reason === "user" ? "active" : null, settledAt: null, + // Re-entry stamp for active-list ordering. A thread already pinned + // active keeps its stamp: the activity reset that clears the pin + // is not a re-entry and must not reorder the list. + unsettledAt: + existingRow.value.settledOverride === "active" + ? existingRow.value.unsettledAt + : event.payload.updatedAt, updatedAt: event.payload.updatedAt, }); return; @@ -757,6 +790,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.managedWorktree !== undefined ? { managedWorktree: event.payload.managedWorktree } : {}), + ...(event.payload.linkedPullRequest !== undefined + ? { linkedPullRequest: event.payload.linkedPullRequest } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -850,7 +886,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); - yield* refreshThreadShellSummary(event.payload.threadId); + if (shouldRefreshThreadShellSummary(event)) { + yield* refreshThreadShellSummary(event.payload.threadId); + } return; } @@ -1527,6 +1565,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const resolvedDecision = resolvedDecisionRaw === "accept" || resolvedDecisionRaw === "acceptForSession" || + resolvedDecisionRaw === "acceptAlways" || resolvedDecisionRaw === "decline" || resolvedDecisionRaw === "cancel" ? resolvedDecisionRaw diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b74d5a14c28c..3fbb3bb804ef 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -83,6 +83,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { interaction_mode, branch, worktree_path, + linked_pull_request_json, latest_turn_id, latest_user_message_at, pending_approval_count, @@ -103,6 +104,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'default', NULL, NULL, + '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', 'turn-1', '2026-02-24T00:00:04.000Z', 1, @@ -335,6 +337,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + linkedPullRequest: { + projectId: asProjectId("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -353,6 +361,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", @@ -474,6 +483,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + linkedPullRequest: { + projectId: asProjectId("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -491,6 +506,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f1e2e6da0b45..cab904c73c18 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -26,6 +26,7 @@ import { type OrchestrationThreadShell, ModelSelection, ProjectId, + ThreadLinkedPullRequest, ThreadId, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -93,6 +94,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), managedWorktree: Schema.NullOr(Schema.fromJsonString(ManagedWorktreeProvenance)), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -453,12 +455,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", managed_worktree_json AS "managedWorktree", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -490,12 +494,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", managed_worktree_json AS "managedWorktree", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -529,12 +535,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", managed_worktree_json AS "managedWorktree", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -1080,12 +1088,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", managed_worktree_json AS "managedWorktree", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -1853,12 +1863,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, managedWorktree: row.managedWorktree, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2159,12 +2173,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, managedWorktree: row.managedWorktree, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2296,12 +2314,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2441,12 +2463,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2720,12 +2746,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...(threadRow.value.linkedPullRequest === null + ? {} + : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + unsettledAt: threadRow.value.unsettledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, @@ -2871,12 +2901,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, managedWorktree: threadRow.value.managedWorktree, + ...(threadRow.value.linkedPullRequest === null + ? {} + : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + unsettledAt: threadRow.value.unsettledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 84bc4f81627f..6673a4151ec8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -250,6 +250,8 @@ describe("ProviderCommandReactor", () => { readonly requiresNewThreadForModelChange?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; + readonly interruptTurnEffect?: () => Effect.Effect; + readonly stopSessionEffect?: () => Effect.Effect; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; @@ -346,23 +348,27 @@ describe("ProviderCommandReactor", () => { const ensurePreTurnBaseline = vi.fn( () => input?.ensurePreTurnBaselineEffect?.() ?? Effect.succeed(null), ); - const interruptTurn = vi.fn((_: unknown) => Effect.void); + const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); - const stopSession = vi.fn((input: unknown) => - Effect.sync(() => { - const threadId = - typeof input === "object" && input !== null && "threadId" in input - ? (input as { threadId?: ThreadId }).threadId - : undefined; - if (!threadId) { - return; - } - const index = runtimeSessions.findIndex((session) => session.threadId === threadId); - if (index >= 0) { - runtimeSessions.splice(index, 1); - } - }), + const stopSession = vi.fn((stopInput: unknown) => + (input?.stopSessionEffect?.() ?? Effect.void).pipe( + Effect.tap(() => + Effect.sync(() => { + const threadId = + typeof stopInput === "object" && stopInput !== null && "threadId" in stopInput + ? (stopInput as { threadId?: ThreadId }).threadId + : undefined; + if (!threadId) { + return; + } + const index = runtimeSessions.findIndex((session) => session.threadId === threadId); + if (index >= 0) { + runtimeSessions.splice(index, 1); + } + }), + ), + ), ); const renameBranch = vi.fn((input: unknown) => Effect.succeed({ @@ -398,6 +404,7 @@ describe("ProviderCommandReactor", () => { }), ); const runSetupScript = vi.fn(() => Effect.succeed({ status: "no-script" as const })); + const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, @@ -476,6 +483,7 @@ describe("ProviderCommandReactor", () => { }, rollbackConversation: () => unsupported(), rollbackConversationTo: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, @@ -557,6 +565,7 @@ describe("ProviderCommandReactor", () => { createWorktree, listRefs, renameBranch, + pruneWorktrees, } satisfies Partial), ), Layer.provideMerge(Layer.succeed(ProjectSetupScriptRunner, { runForThread: runSetupScript })), @@ -695,6 +704,7 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, listRefs, + pruneWorktrees, createWorktree, runSetupScript, refreshStatus, @@ -1950,6 +1960,50 @@ describe("ProviderCommandReactor", () => { }); }); + it("recreates a missing worktree from the thread branch before starting a turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const worktreePath = NodePath.join(harness.stateDir, "missing-worktree"); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-missing-worktree"), + threadId: ThreadId.make("thread-1"), + branch: "feature/restore", + worktreePath, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-worktree"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project", + refName: "feature/restore", + path: worktreePath, + }); + expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( + harness.startSession.mock.invocationCallOrder[0]!, + ); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2926,6 +2980,218 @@ describe("ProviderCommandReactor", () => { }); }); + effectIt.effect( + "stops a running session and records the failure when provider interrupt fails", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ + interruptTurnEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.interrupt", + detail: "provider session disappeared", + }), + ), + stopSessionEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "session.stop", + detail: "provider process already exited", + }), + ), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-failure"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-provider-failure"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-1"), + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "stopped"; + }), + ); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "stopped", + activeTurnId: null, + lastError: "provider session disappeared", + }); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toMatchObject({ + summary: "Provider turn interrupt failed", + payload: { detail: "provider session disappeared" }, + }); + expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); + }), + ); + + effectIt.effect("stops a starting session without a bound turn when interrupt fails", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ + interruptTurnEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.interrupt", + detail: "provider session disappeared", + }), + ), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-starting"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-starting-provider-failure"), + threadId: ThreadId.make("thread-1"), + createdAt: now, + }); + + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "stopped", + activeTurnId: null, + lastError: "provider session disappeared", + }); + expect(harness.stopSession).toHaveBeenCalledWith({ threadId: ThreadId.make("thread-1") }); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toMatchObject({ payload: { detail: "provider session disappeared" } }); + }), + ); + + effectIt.effect("does not overwrite a session that became ready while an interrupt failed", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + const completedAt = "2026-01-01T00:00:01.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-race"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + harness.interruptTurn.mockImplementation(() => + harness.engine + .dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-natural-completion"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: completedAt, + }, + createdAt: completedAt, + }) + .pipe( + Effect.catchCause((cause) => Effect.die(cause)), + Effect.andThen( + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "thread.interrupt", + detail: "provider session disappeared", + }), + ), + ), + ), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-race"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-1"), + createdAt: now, + }); + + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "ready", + activeTurnId: null, + lastError: null, + updatedAt: completedAt, + }); + expect(harness.stopSession).not.toHaveBeenCalled(); + expect( + thread?.activities.some((activity) => activity.kind === "provider.turn.interrupt.failed"), + ).toBe(false); + }), + ); + it("starts a fresh session when only projected session state exists", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3f91ab65dc48..3a918d28d3e1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -22,13 +22,13 @@ import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX, } from "@t3tools/shared/git"; -import * as FileSystem from "effect/FileSystem"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -500,6 +500,57 @@ const make = Effect.gen(function* () { .pipe(Effect.map(Option.getOrUndefined)); }); + /** + * Recreates a thread's worktree from its branch when the directory has + * disappeared. Provider sessions resume into the persisted cwd, so a missing + * worktree makes every later turn fail as a bogus "session not found". + * Best-effort: on failure the turn proceeds and reports the real error. + */ + const ensureThreadWorktree = Effect.fnUntraced(function* (thread: { + readonly id: ThreadId; + readonly projectId: ProjectId; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly managedWorktree?: unknown; + }) { + const { worktreePath, branch } = thread; + // Worktrees created by first-send bootstrap carry provenance and are + // recreated by ensureThreadWorkspaceCwd, which can also recover a pruned + // branch from refs or checkpoints. Only unmanaged worktrees use this + // best-effort path. + if (!worktreePath || !branch || thread.managedWorktree != null) { + return; + } + const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true)); + if (exists) { + return; + } + const project = yield* resolveProject(thread.projectId); + if (!project) { + return; + } + const cwd = project.workspaceRoot; + yield* Effect.logWarning("provider command reactor recreating missing worktree", { + threadId: thread.id, + worktreePath, + branch, + }); + // A directory deleted without `git worktree remove` leaves an admin entry + // that makes `git worktree add` refuse the path; prune clears it. + yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( + Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("provider command reactor failed to recreate worktree", { + threadId: thread.id, + worktreePath, + cause: Cause.pretty(cause), + }), + ), + ); + }); + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery .getThreadDetailById(threadId) @@ -1230,6 +1281,8 @@ const make = Effect.gen(function* () { return; } + yield* ensureThreadWorktree(thread); + const isFirstUserMessageTurn = thread.messages.filter((entry) => entry.role === "user").length === 1; if (isFirstUserMessageTurn) { @@ -1372,8 +1425,8 @@ const make = Effect.gen(function* () { return; } } - const hasSession = thread.session && thread.session.status !== "stopped"; - if (!hasSession) { + const session = thread.session; + if (!session || session.status === "stopped") { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.interrupt.failed", @@ -1384,8 +1437,80 @@ const make = Effect.gen(function* () { }); } + const recoverInterruptFailure = (cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + + const detail = formatFailureDetail(cause); + return Effect.gen(function* () { + const latestThread = yield* resolveThread(event.payload.threadId); + const latestSession = latestThread?.session; + if ( + !latestSession || + latestSession.status === "stopped" || + latestSession.status === "ready" || + (event.payload.turnId !== undefined && + latestSession.activeTurnId !== null && + latestSession.activeTurnId !== event.payload.turnId) + ) { + return; + } + + yield* providerService.stopSession({ threadId: event.payload.threadId }).pipe( + Effect.catchCause((stopCause) => { + if (Cause.hasInterruptsOnly(stopCause)) { + return Effect.interrupt; + } + return Effect.logWarning( + "provider command reactor failed to stop session after interrupt failure", + { + threadId: event.payload.threadId, + cause: Cause.pretty(stopCause), + originalCause: Cause.pretty(cause), + }, + ); + }), + ); + const stoppedThread = yield* resolveThread(event.payload.threadId); + const stoppedSession = stoppedThread?.session; + if ( + !stoppedSession || + stoppedSession.status === "stopped" || + stoppedSession.status === "ready" || + (event.payload.turnId !== undefined && + stoppedSession.activeTurnId !== null && + stoppedSession.activeTurnId !== event.payload.turnId) + ) { + return; + } + + yield* setThreadSession({ + threadId: event.payload.threadId, + session: { + ...stoppedSession, + status: "stopped", + activeTurnId: null, + lastError: detail, + updatedAt: event.payload.createdAt, + }, + createdAt: event.payload.createdAt, + }); + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.interrupt.failed", + summary: "Provider turn interrupt failed", + detail, + turnId: event.payload.turnId ?? null, + createdAt: event.payload.createdAt, + }); + }); + }; + // Orchestration turn ids are not provider turn ids, so interrupt by session. - yield* providerService.interruptTurn({ threadId: event.payload.threadId }); + yield* providerService + .interruptTurn({ threadId: event.payload.threadId }) + .pipe(Effect.catchCause(recoverInterruptFailure)); }); const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts index 05370781c0d0..0d262028dedf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts @@ -30,4 +30,41 @@ describe("runtimeEventToActivities approval details", () => { expect(activity?.kind).toBe("approval.requested"); expect((activity?.payload as Record | undefined)?.detail).toBe(detail); }); + + it("keeps app details and approval options available to remote clients", () => { + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ] as const; + const event = { + type: "request.opened", + eventId: EventId.make("evt-mcp-elicitation"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-08-24T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + requestId: RuntimeRequestId.make("approval-safari"), + payload: { + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + } satisfies ProviderRuntimeEvent; + + const [activity] = runtimeEventToActivities(event); + + expect(activity).toMatchObject({ + kind: "approval.requested", + summary: "App access approval requested", + payload: { + requestId: "approval-safari", + requestKind: "mcp-elicitation", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b4c22bc290f3..500a6315e106 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -264,6 +264,7 @@ function createProviderServiceHarness() { }, rollbackConversation: () => unsupported(), rollbackConversationTo: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, @@ -3085,11 +3086,16 @@ describe("ProviderRuntimeIngestion", () => { createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-9"), + itemId: asItemId("tool-call-9"), payload: { itemType: "command_execution", - status: "in_progress", - title: "Read file", - detail: "/tmp/file.ts", + status: "inProgress", + title: "Command run", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, }, }); @@ -3104,11 +3110,20 @@ describe("ProviderRuntimeIngestion", () => { ); expect(thread.session?.status).toBe("ready"); - expect( - thread.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", - ), - ).toBe(true); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started", + ); + const payload = activity?.payload as Record | undefined; + expect(payload).toMatchObject({ + itemType: "command_execution", + toolCallId: "tool-call-9", + status: "inProgress", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, + }); }); it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { @@ -3226,6 +3241,7 @@ describe("ProviderRuntimeIngestion", () => { expect(toolUpdate?.kind).toBe("tool.updated"); expect(toolUpdatePayload?.itemType).toBe("command_execution"); expect(toolUpdatePayload?.status).toBe("in_progress"); + expect(toolUpdatePayload?.toolCallId).toBe("item-p1-tool"); const warning = thread.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-runtime-warning", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index dee62e44cb3e..6195f98598b1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -355,7 +355,7 @@ function sessionStatusAllowsActiveTurn( function requestKindFromCanonicalRequestType( requestType: string | undefined, -): "command" | "file-read" | "file-change" | undefined { +): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined { switch (requestType) { case "command_execution_approval": case "exec_command_approval": @@ -365,6 +365,8 @@ function requestKindFromCanonicalRequestType( case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "mcp_elicitation_approval": + return "mcp-elicitation"; default: return undefined; } @@ -474,12 +476,16 @@ export function runtimeEventToActivities( ? "File-read approval requested" : requestKind === "file-change" ? "File-change approval requested" - : "Approval requested", + : requestKind === "mcp-elicitation" + ? "App access approval requested" + : "Approval requested", payload: { requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.detail ? { detail: event.payload.detail } : {}), + ...(event.payload.appName ? { appName: event.payload.appName } : {}), + ...(event.payload.options ? { options: event.payload.options } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -897,6 +903,7 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool updated", payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), @@ -925,6 +932,8 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), @@ -952,7 +961,10 @@ export function runtimeEventToActivities( summary: `${event.payload.title ?? "Tool"} started`, payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), ...(event.payload.parentToolUseId ? { parentToolUseId: event.payload.parentToolUseId } diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 46a25744c906..089ce9f84052 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -73,6 +73,7 @@ function projectedThread(patch: Partial = {}): ProjectionThrea archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, diff --git a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts index 970224d236a9..37456e090b52 100644 --- a/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/TurnRetractionReactor.test.ts @@ -325,6 +325,7 @@ async function startHarness( respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), discardTransientThread: () => unsupported(), + uploadFeedback: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts new file mode 100644 index 000000000000..27a35977ffca --- /dev/null +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -0,0 +1,318 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + type ClientOrchestrationCommand, + CommandId, + MessageId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts"; + +const testLayer = Layer.mergeAll( + WorkspacePaths.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-normalizer-attachments-" }), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const attachmentUuid = "00000000-0000-4000-8000-0000000000aa"; + +function turnStartCommand(input: { + readonly threadId?: string; + readonly attachments: ReadonlyArray< + | { readonly id: string; readonly sizeBytes: number } + | { readonly dataUrl: string; readonly sizeBytes: number } + >; +}): ClientOrchestrationCommand { + return { + type: "thread.turn.start", + commandId: CommandId.make("command-1"), + threadId: ThreadId.make(input.threadId ?? "thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "look at this", + attachments: input.attachments.map((attachment) => ({ + type: "image" as const, + name: "screenshot.png", + mimeType: "image/png", + ...attachment, + })), + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-01T00:00:00.000Z", + }; +} + +describe("normalizeDispatchCommand attachments", () => { + it.effect("preserves inline image attachments from existing mobile clients", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }], + }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const attachment = normalized.message.attachments[0]!; + expect(attachment.id.startsWith("thread-1-")).toBe(true); + expect( + NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${attachment.id}.png`)), + ).toEqual(Buffer.from("pixels")); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("claims uploaded attachments while retaining a retryable pending copy", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("pixels"); + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, bytes); + + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], + }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const attachmentId = normalized.message.attachments[0]!.id; + expect(attachmentId.startsWith("thread-1-")).toBe(true); + expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`); + expect(NodeFS.existsSync(pendingPath)).toBe(true); + expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe( + true, + ); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("normalizes inline and uploaded attachments in the same turn", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), + Buffer.from("pixels"), + ); + + const normalized = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [ + { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }, + { id: `pending-${attachmentUuid}`, sizeBytes: 6 }, + ], + }), + ); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + expect(normalized.message.attachments).toHaveLength(2); + expect(normalized.message.attachments[1]?.id.startsWith("thread-1-")).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("retries a failed bootstrap with a fresh thread id", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const bytes = Buffer.from("pixels"); + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), + bytes, + ); + + const first = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], + }), + ); + if (first.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + NodeFS.rmSync( + NodePath.join(config.attachmentsDir, `${first.message.attachments[0]!.id}.png`), + ); + + const retried = yield* normalizeDispatchCommand( + turnStartCommand({ + threadId: "thread-retry", + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], + }), + ); + if (retried.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + expect(retried.message.attachments[0]?.id.startsWith("thread-retry-")).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes failed attachment claims without deleting their pending uploads", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + const command = turnStartCommand({ + attachments: [ + { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }, + { id: `pending-${attachmentUuid}`, sizeBytes: 6 }, + ], + }); + const normalized = yield* normalizeDispatchCommand(command); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const inlinePath = NodePath.join( + config.attachmentsDir, + `${normalized.message.attachments[0]!.id}.png`, + ); + const claimedPath = NodePath.join( + config.attachmentsDir, + `${normalized.message.attachments[1]!.id}.png`, + ); + yield* cleanupFailedUploadedAttachments(command, normalized); + + expect(NodeFS.existsSync(pendingPath)).toBe(true); + expect(NodeFS.existsSync(claimedPath)).toBe(false); + expect(NodeFS.existsSync(inlinePath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes a failed claimed copy after its pending original was removed", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + const command = turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], + }); + const normalized = yield* normalizeDispatchCommand(command); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const claimedPath = NodePath.join( + config.attachmentsDir, + `${normalized.message.attachments[0]!.id}.png`, + ); + NodeFS.rmSync(pendingPath); + + yield* cleanupFailedUploadedAttachments(command, normalized); + + expect(NodeFS.existsSync(claimedPath)).toBe(false); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps concurrent claims independent when one dispatch fails", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + const command = turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], + }); + + const [failed, succeeded] = yield* Effect.all( + [normalizeDispatchCommand(command), normalizeDispatchCommand(command)], + { concurrency: 2 }, + ); + if (failed.type !== "thread.turn.start" || succeeded.type !== "thread.turn.start") { + throw new Error("Expected thread.turn.start commands."); + } + + const failedPath = NodePath.join( + config.attachmentsDir, + `${failed.message.attachments[0]!.id}.png`, + ); + const succeededPath = NodePath.join( + config.attachmentsDir, + `${succeeded.message.attachments[0]!.id}.png`, + ); + expect(failedPath).not.toBe(succeededPath); + + yield* cleanupFailedUploadedAttachments(command, failed); + + expect(NodeFS.existsSync(pendingPath)).toBe(true); + expect(NodeFS.existsSync(failedPath)).toBe(false); + expect(NodeFS.existsSync(succeededPath)).toBe(true); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes earlier claimed copies when a later attachment cannot be normalized", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingId = `pending-${attachmentUuid}`; + const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.png`); + NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); + + const failure = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [ + { id: pendingId, sizeBytes: 6 }, + { + id: "pending-00000000-0000-4000-8000-0000000000ff", + sizeBytes: 6, + }, + ], + }), + ).pipe(Effect.flip); + + expect(failure.message).toContain("not found"); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${pendingId}.png`]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects uploaded attachments with the wrong size or thread", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), + Buffer.from("pixels"), + ); + + const wrongSize = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 999 }], + }), + ).pipe(Effect.flip); + expect(wrongSize.message).toContain("size"); + + const wrongThread = yield* normalizeDispatchCommand( + turnStartCommand({ + attachments: [{ id: `another-thread-${attachmentUuid}`, sizeBytes: 6 }], + }), + ).pipe(Effect.flip); + expect(wrongThread.message).toContain("pending upload"); + + const mismatchedTypeCommand = turnStartCommand({ + attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], + }); + if (mismatchedTypeCommand.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + const mismatchedType = yield* normalizeDispatchCommand({ + ...mismatchedTypeCommand, + message: { + ...mismatchedTypeCommand.message, + attachments: mismatchedTypeCommand.message.attachments.map((attachment) => ({ + ...attachment, + mimeType: "image/jpeg", + })), + }, + }).pipe(Effect.flip); + expect(mismatchedType.message).toContain("image type"); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..bd6a8f242b87 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -10,7 +10,13 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { + createAttachmentId, + planAttachmentClaim, + PENDING_ATTACHMENT_THREAD_SEGMENT, + parseThreadSegmentFromAttachmentId, + resolveAttachmentPath, +} from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -43,6 +49,29 @@ export const canonicalizeClientCommandTimestamps = ( }; }; +const removeClaimedAttachmentPaths = Effect.fn("Normalizer.removeClaimedAttachmentPaths")( + function* (attachmentPaths: ReadonlyArray) { + if (attachmentPaths.length === 0) { + return; + } + const fileSystem = yield* FileSystem.FileSystem; + yield* Effect.forEach( + attachmentPaths, + (attachmentPath) => + fileSystem.remove(attachmentPath, { force: true }).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to remove an unclaimed attachment copy.", { + attachmentPath, + cause, + }), + ), + Effect.orElseSucceed(() => undefined), + ), + { concurrency: 1 }, + ); + }, +); + export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { const receivedAt = DateTime.formatIso(yield* DateTime.now); @@ -104,10 +133,69 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => return canonicalCommand as OrchestrationCommand; } + const claimedAttachmentPaths: string[] = []; const normalizedAttachments = yield* Effect.forEach( canonicalCommand.message.attachments, (attachment) => Effect.gen(function* () { + if (!("dataUrl" in attachment)) { + const claim = planAttachmentClaim({ + attachmentsDir: serverConfig.attachmentsDir, + threadId: canonicalCommand.threadId, + attachmentId: attachment.id, + }); + if (!claim.ok) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: ${claim.reason}.`, + }); + } + + const info = yield* fileSystem.stat(claim.currentPath).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`, + cause, + }), + ), + ); + if (Number(info.size) !== attachment.sizeBytes) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: stored size does not match.`, + }); + } + + const normalizedAttachment = { + ...attachment, + id: claim.finalId, + mimeType: attachment.mimeType.toLowerCase(), + }; + const expectedPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment: normalizedAttachment, + }); + if (expectedPath !== claim.finalPath) { + return yield* new OrchestrationDispatchCommandError({ + message: `Attachment '${attachment.name}' cannot be sent: image type does not match the upload.`, + }); + } + + // Keep the pending copy until the turn succeeds. A failed thread + // bootstrap can then retry with a fresh thread id. + yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: `Failed to claim attachment '${attachment.name}' for this thread.`, + cause, + }), + ), + ); + claimedAttachmentPaths.push(claim.finalPath); + + return normalizedAttachment; + } + const parsed = parseBase64DataUrl(attachment.dataUrl); if (!parsed || !parsed.mimeType.startsWith("image/")) { return yield* new OrchestrationDispatchCommandError({ @@ -167,7 +255,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => return persistedAttachment; }), { concurrency: 1 }, - ); + ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths))); return { ...canonicalCommand, @@ -177,3 +265,33 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }, } satisfies OrchestrationCommand; }); + +export const cleanupFailedUploadedAttachments = Effect.fn( + "Normalizer.cleanupFailedUploadedAttachments", +)(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) { + if (command.type !== "thread.turn.start" || normalizedCommand.type !== "thread.turn.start") { + return; + } + + const serverConfig = yield* ServerConfig; + const claimedPaths: string[] = []; + for (const [index, attachment] of normalizedCommand.message.attachments.entries()) { + const original = command.message.attachments[index]; + if ( + !original || + "dataUrl" in original || + parseThreadSegmentFromAttachmentId(original.id) !== PENDING_ATTACHMENT_THREAD_SEGMENT + ) { + continue; + } + + const claimedPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (claimedPath) { + claimedPaths.push(claimedPath); + } + } + yield* removeClaimedAttachmentPaths(claimedPaths); +}); diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index f8bcfd76ac06..a32a45684014 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -10,7 +10,11 @@ * * @module OrchestrationEngineService */ -import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts"; +import type { + OrchestrationClientOrigin, + OrchestrationCommand, + OrchestrationEvent, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; @@ -41,6 +45,8 @@ export interface OrchestrationEngineShape { * Dispatch a validated orchestration command. * * @param command - Valid orchestration command. + * @param options - Optional client origin (surface/app version) stamped into + * the metadata of every event the command produces. * @returns Effect containing the sequence of the persisted event. * * Dispatch is serialized through an internal queue and deduplicated via @@ -48,6 +54,7 @@ export interface OrchestrationEngineShape { */ readonly dispatch: ( command: OrchestrationCommand, + options?: { readonly origin?: OrchestrationClientOrigin }, ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; /** diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 20bc3475613a..26927d4499d6 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -5,6 +5,7 @@ import { ProjectId, ProviderInstanceId, ThreadId, + type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationSession, type OrchestrationThread, @@ -14,6 +15,7 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; const SETTLED_AT = "2025-12-30T00:00:00.000Z"; @@ -428,6 +430,42 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ); + // Command-to-projection: an accepted un-settle must land as the re-entry + // stamp clients sort by (max of createdAt and unsettledAt, see + // activeThreadAnchorTimestampMs in client-runtime), so the thread surfaces + // above threads created after it. The projector tests feed events directly; + // this one proves the decider actually emits what they consume. + it.effect("an accepted un-settle re-anchors the thread for the active list", () => + Effect.gen(function* () { + const readModel = makeReadModel("settled"); + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-anchor"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel, + }); + const events = Array.isArray(result) ? result : [result]; + const unsettled = events[0]!; + expect(unsettled.type).toBe("thread.unsettled"); + + const projected = yield* projectEvent(readModel, { + ...unsettled, + sequence: readModel.snapshotSequence + 1, + } as OrchestrationEvent); + const thread = projected.threads[0]!; + expect(thread.settledOverride).toBe("active"); + // The stamp is the decider's accept time: every thread created before + // the un-settle anchors below it. + expect(thread.unsettledAt).toBe(unsettled.occurredAt); + if (unsettled.type === "thread.unsettled") { + expect(thread.unsettledAt).toBe(unsettled.payload.updatedAt); + } + }), + ); + it.effect("prepends activity unsets for turn starts and live session updates", () => Effect.gen(function* () { const turnResult = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3e6e5fa4e276..764084de4f08 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -938,6 +938,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.linkedPullRequest !== undefined + ? { linkedPullRequest: command.linkedPullRequest } + : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 04d54ea8effb..f7147106c7a9 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -8,7 +8,7 @@ import * as Option from "effect/Option"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; -import { normalizeDispatchCommand } from "./Normalizer.ts"; +import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts"; import { annotateEnvironmentRequest, failEnvironmentInternal, @@ -96,13 +96,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), ); - return yield* orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.catch((cause) => - failEnvironmentInternal("orchestration_dispatch_failed", cause), - ), - ); + return yield* orchestrationEngine.dispatch(normalizedCommand).pipe( + Effect.tapError(() => + cleanupFailedUploadedAttachments(args.payload, normalizedCommand), + ), + Effect.catch((cause) => + failEnvironmentInternal("orchestration_dispatch_failed", cause), + ), + ); }), ); }), diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts index 2070c44418a4..7c9395e6d2bd 100644 --- a/apps/server/src/orchestration/projector.settled.test.ts +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -62,27 +62,62 @@ it.effect("projects settled lifecycle events", () => ); expect(settled.threads[0]?.settledOverride).toBe("settled"); expect(settled.threads[0]?.settledAt).toBe(now); + expect(settled.threads[0]?.unsettledAt).toBeNull(); + const unsettleAt = "2026-01-02T00:00:00.000Z"; const userUnsettled = yield* projectEvent( settled, makeEvent({ sequence: 3, type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: unsettleAt }, }), ); expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + expect(userUnsettled.threads[0]?.unsettledAt).toBe(unsettleAt); + // Clearing the keep-active pin on activity is not a re-entry: the thread + // is already in the active list, so the stamp must not move it. + const activityAt = "2026-01-03T00:00:00.000Z"; const activityUnsettled = yield* projectEvent( userUnsettled, makeEvent({ sequence: 4, type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: activityAt }, }), ); expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + expect(activityUnsettled.threads[0]?.unsettledAt).toBe(unsettleAt); + + const resettledAt = "2026-01-04T00:00:00.000Z"; + const resettled = yield* projectEvent( + activityUnsettled, + makeEvent({ + sequence: 5, + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt: resettledAt, + updatedAt: resettledAt, + }, + }), + ); + expect(resettled.threads[0]?.unsettledAt).toBeNull(); + + // Waking a settled thread on activity IS a re-entry and stamps. + const wakeAt = "2026-01-05T00:00:00.000Z"; + const woke = yield* projectEvent( + resettled, + makeEvent({ + sequence: 6, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: wakeAt }, + }), + ); + expect(woke.threads[0]?.settledOverride).toBeNull(); + expect(woke.threads[0]?.unsettledAt).toBe(wakeAt); }), ); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index b20cbf4e6109..bab047999a0a 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -92,6 +92,7 @@ describe("orchestration projector", () => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 4d88c65236c3..2d2721f00333 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -284,6 +284,7 @@ export function projectEvent( archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -345,6 +346,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { settledOverride: "settled", settledAt: payload.settledAt, + unsettledAt: null, updatedAt: payload.updatedAt, }), })), @@ -352,14 +354,24 @@ export function projectEvent( case "thread.unsettled": return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - settledOverride: payload.reason === "user" ? "active" : null, - settledAt: null, - updatedAt: payload.updatedAt, - }), - })), + Effect.map((payload) => { + const existing = nextBase.threads.find((thread) => thread.id === payload.threadId); + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + // Re-entry stamp for active-list ordering. A thread already + // pinned active keeps its stamp: the activity reset that clears + // the pin is not a re-entry and must not reorder the list. + unsettledAt: + existing?.settledOverride === "active" + ? (existing.unsettledAt ?? null) + : payload.updatedAt, + updatedAt: payload.updatedAt, + }), + }; + }), ); case "thread.snoozed": @@ -440,6 +452,9 @@ export function projectEvent( ...(payload.managedWorktree !== undefined ? { managedWorktree: payload.managedWorktree } : {}), + ...(payload.linkedPullRequest !== undefined + ? { linkedPullRequest: payload.linkedPullRequest } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 545688e38228..579d3a608190 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -10,6 +10,7 @@ import { AuthClientMetadataDeviceType, AuthEnvironmentScopes, AuthSessionId, + ClientSurface, ServerAuthSessionMethod, } from "@t3tools/contracts"; @@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ }); export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; +export const SetAuthSessionClientConnectionInput = Schema.Struct({ + sessionId: AuthSessionId, + surface: Schema.NullOr(ClientSurface), + appVersion: Schema.NullOr(Schema.String), +}); +export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type; + export class AuthSessionRepository extends Context.Service< AuthSessionRepository, { @@ -103,6 +111,9 @@ export class AuthSessionRepository extends Context.Service< readonly setLastConnectedAt: ( input: SetAuthSessionLastConnectedAtInput, ) => Effect.Effect; + readonly setClientConnection: ( + input: SetAuthSessionClientConnectionInput, + ) => Effect.Effect; } >()("t3/persistence/AuthSessions/AuthSessionRepository") {} @@ -281,6 +292,20 @@ export const make = Effect.gen(function* () { `, }); + // COALESCE keeps the previous value when a client reports only one field, so + // a partial report never nulls out data a fuller client stored earlier. + const setClientConnectionRow = SqlSchema.void({ + Request: SetAuthSessionClientConnectionInput, + execute: ({ sessionId, surface, appVersion }) => + sql` + UPDATE auth_sessions + SET client_surface = COALESCE(${surface}, client_surface), + client_app_version = COALESCE(${appVersion}, client_app_version) + WHERE session_id = ${sessionId} + AND revoked_at IS NULL + `, + }); + const revokeSessionRows = SqlSchema.findAll({ Request: RevokeAuthSessionInput, Result: Schema.Struct({ sessionId: AuthSessionId }), @@ -404,6 +429,17 @@ export const make = Effect.gen(function* () { ), ); + const setClientConnection: AuthSessionRepository["Service"]["setClientConnection"] = (input) => + setClientConnectionRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.setClientConnection:query", + "AuthSessionRepository.setClientConnection:encodeRequest", + { sessionId: input.sessionId }, + ), + ), + ); + return { create, getById, @@ -411,6 +447,7 @@ export const make = Effect.gen(function* () { revoke, revokeAllExcept, setLastConnectedAt, + setClientConnection, } satisfies AuthSessionRepository["Service"]; }); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index c3d377548a72..00f50907a22e 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -159,6 +160,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: "settled", settledAt: "2026-03-25T00:00:00.000Z", + unsettledAt: null, snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", pinnedAt: "2026-03-25T00:00:00.000Z", @@ -188,6 +190,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { ...row, settledOverride: "active", settledAt: null, + unsettledAt: "2026-03-26T00:00:00.000Z", snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -198,9 +201,63 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const updated = Option.getOrNull(repersisted); assert.strictEqual(updated?.settledOverride, "active"); assert.strictEqual(updated?.settledAt, null); + assert.strictEqual(updated?.unsettledAt, "2026-03-26T00:00:00.000Z"); assert.strictEqual(updated?.snoozedUntil, null); assert.strictEqual(updated?.snoozedAt, null); assert.strictEqual(updated?.pinnedAt, null); }), ); + + it.effect("round-trips a linked pull request through the thread row", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const linkedPullRequest = { + projectId: ProjectId.make("project-linked-pr"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-linked-pr"), + projectId: ProjectId.make("project-linked-pr"), + title: "Linked pull request", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + managedWorktree: null, + linkedPullRequest, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + unsettledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); + assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest); + + const row = Option.getOrNull(persisted); + if (row === null) return yield* Effect.die("Expected linked thread row to exist."); + yield* threads.upsert({ ...row, linkedPullRequest: null }); + + const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); + assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 02a62a9038a3..f4bf628f5a3b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -16,12 +16,17 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ManagedWorktreeProvenance, ModelSelection } from "@t3tools/contracts"; +import { + ManagedWorktreeProvenance, + ModelSelection, + ThreadLinkedPullRequest, +} from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), managedWorktree: Schema.NullOr(Schema.fromJsonString(ManagedWorktreeProvenance)), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -43,12 +48,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path, managed_worktree_json, + linked_pull_request_json, latest_turn_id, created_at, updated_at, archived_at, settled_override, settled_at, + unsettled_at, snoozed_until, snoozed_at, pinned_at, @@ -71,12 +78,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.branch}, ${row.worktreePath}, ${row.managedWorktree === null ? null : JSON.stringify(row.managedWorktree)}, + ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, ${row.settledOverride}, ${row.settledAt}, + ${row.unsettledAt}, ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, @@ -99,12 +108,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch = excluded.branch, worktree_path = excluded.worktree_path, managed_worktree_json = excluded.managed_worktree_json, + linked_pull_request_json = excluded.linked_pull_request_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, settled_override = excluded.settled_override, settled_at = excluded.settled_at, + unsettled_at = excluded.unsettled_at, snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, @@ -134,12 +145,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", managed_worktree_json AS "managedWorktree", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -171,12 +184,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", managed_worktree_json AS "managedWorktree", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..8abbe87fce3e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,9 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; +import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; +import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +108,9 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "AuthSessionClientConnection", Migration0041], + [42, "ProjectionThreadLinkedPullRequest", Migration0042], + [43, "ProjectionThreadsUnsettledAt", Migration0043], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts new file mode 100644 index 000000000000..178338b78318 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts @@ -0,0 +1,31 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("041_AuthSessionClientConnection", (it) => { + it.effect("adds nullable client surface and app version columns to auth sessions", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + yield* runMigrations({ toMigrationInclusive: 41 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(auth_sessions) + `; + const surface = columns.find((column) => column.name === "client_surface"); + const appVersion = columns.find((column) => column.name === "client_app_version"); + + assert.equal(surface?.name, "client_surface"); + assert.equal(surface?.notnull, 0); + assert.equal(appVersion?.name, "client_app_version"); + assert.equal(appVersion?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts new file mode 100644 index 000000000000..2194c3cd0f14 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Client-declared surface (web/desktop/mobile) and app version, refreshed on +// every WebSocket connect so the row tracks the client's current build instead +// of freezing at session issuance. Nullable: old clients never report them. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!columns.some((column) => column.name === "client_surface")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_surface TEXT + `; + } + + if (!columns.some((column) => column.name === "client_app_version")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_app_version TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts new file mode 100644 index 000000000000..1fe59df50729 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts @@ -0,0 +1,25 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("042_ProjectionThreadLinkedPullRequest", (it) => { + it.effect("adds the linked pull request column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 41 }); + yield* runMigrations({ toMigrationInclusive: 42 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "linked_pull_request_json")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts new file mode 100644 index 000000000000..a026f39c392a --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "linked_pull_request_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN linked_pull_request_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts b/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts new file mode 100644 index 000000000000..981d3c78f3a6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "unsettled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN unsettled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a662ebe990d3..cdf238b30739 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -15,6 +15,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadLinkedPullRequest, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -35,12 +36,14 @@ export const ProjectionThread = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), managedWorktree: Schema.NullOr(ManagedWorktreeProvenance), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), settledAt: Schema.NullOr(IsoDateTime), + unsettledAt: Schema.NullOr(IsoDateTime), snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1ab6166e92a1..a72b42b60b75 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -15,18 +20,30 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; -function makeMockDetachedHandle(onUnref: () => void = () => undefined) { +interface MockSpawnResult { + readonly exitCode?: number; + readonly stdout?: string; + /** Never deliver an exit code, like a child wedged on a broken desktop session. */ + readonly stall?: boolean; +} + +function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () => void } = {}) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: input.stall + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), isRunning: Effect.succeed(true), kill: () => Effect.void, unref: Effect.sync(() => { - onUnref(); + input.onUnref?.(); return Effect.void; }), stdin: Sink.drain, - stdout: Stream.empty, + stdout: + input.stdout === undefined + ? Stream.empty + : Stream.make(new TextEncoder().encode(input.stdout)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -40,6 +57,7 @@ const testLayer = (input: { readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; + readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined; }) => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -50,7 +68,10 @@ const testLayer = (input: { throw new Error("Expected a standard command"); } input.onSpawn?.(command); - return makeMockDetachedHandle(input.onUnref); + return makeMockDetachedHandle({ + ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }), + ...input.spawnResult?.(command), + }); }), ), ); @@ -132,6 +153,623 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("reveals a file in Finder with open -R on macOS", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const openPath = path.join(binDir, "open"); + yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(openPath, 0o755); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "open"); + assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a file in File Explorer through PowerShell on Windows", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + // resolvePowerShellPath builds `${SYSTEMROOT}\System32\...` with Windows + // separators, which on the posix test filesystem is one file name. + const systemRoot = path.join(binDir, "system-root"); + const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true }); + yield* fileSystem.writeFileString(powerShellPath, ""); + + let spawned: ChildProcess.StandardCommand | undefined; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "C:\\workspace with spaces\\media\\author's clip.mp4", + reveal: true, + }); + return yield* launcher.resolveFileManagerRevealKind(); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(kind, "file-explorer"); + assert.ok(spawned); + assert.equal(spawned.command, powerShellPath); + assert.deepEqual(spawned.args.slice(0, -1), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + // explorer.exe expects `/select,""` with only the path quoted; + // PowerShell 5.1's Start-Process passes the argument string verbatim. + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", + ); + assert.equal(spawned.options.shell, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Real-chain smoke check for the Explorer selection contract: runs the exact +// PowerShell source the reveal launch encodes, against a stub that records +// the raw argument tail it receives, and asserts a spaced path arrives as the +// single `/select,""` switch. Mock argv assertions cannot prove this — +// only Windows' own PowerShell -> CreateProcess quoting chain can, so the +// test runs only where that chain exists. +// oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime. +it.skipIf(process.platform !== "win32")( + "delivers the raw /select switch for spaced paths through real PowerShell", + { timeout: 60_000 }, + async () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); + try { + const recorderPath = NodePath.join(tempDir, "recorder.cmd"); + const outputPath = NodePath.join(tempDir, "argv.txt"); + NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`); + + const target = "C:\\workspace with spaces\\media\\author's clip.mp4"; + const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(recorderPath, target); + const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + NodeChildProcess.execFileSync( + powerShellPath, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(source, "utf16le").toString("base64"), + ], + { timeout: 30_000 }, + ); + + // Start-Process returns before the recorder runs; wait for its output. + // The waits run outside the Effect runtime on purpose: the test + // exercises the real Windows process chain in real time. + // @effect-diagnostics-next-line globalTimers:off + const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis)); + // @effect-diagnostics-next-line globalDate:off + const deadline = Date.now() + 20_000; + // @effect-diagnostics-next-line globalDate:off + while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) { + await sleep(100); + } + await sleep(200); + const recorded = NodeFS.readFileSync(outputPath, "utf8").trim(); + assert.equal(recorded, `/select,"${target}"`); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }, +); + +it.effect("does not advertise reveal on Windows when PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { + PATH: binDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + SYSTEMROOT: path.join(binDir, "missing-system-root"), + }, + }), + ), + ); + + // Plain "open in file manager" still works through explorer; only the + // reveal capability, which launches PowerShell, must stay hidden. + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a WSL file in Windows File Explorer through its UNC path", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const kind = yield* launcher.resolveFileManagerRevealKind(); + const editors = yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { kind, editors }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(result.kind, "file-explorer"); + assert.equal(result.editors.includes("file-manager"), true); + assert.ok(spawned); + // The reveal routes through interop PowerShell so Explorer receives its + // raw `/select,""` switch even for spaced paths. + assert.equal(spawned.command, "powershell.exe"); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise reveal from WSL when interop PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const explorerPath = path.join(binDir, "explorer.exe"); + yield* fileSystem.writeFileString(explorerPath, ""); + yield* fileSystem.chmod(explorerPath, 0o755); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// When interop PowerShell is missing the capability advertises the Linux +// "files" kind (or nothing), so the reveal must open the Linux file manager +// the label promised even though plain open still prefers File Explorer. +it.effect("reveals through the Linux file manager when WSL lacks interop PowerShell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const revealKind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return revealKind; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Interop can exist without `explorer.exe` on PATH (appendWindowsPath=false) +// while WSLg still provides a working Linux file manager; the host must keep +// the Linux open/reveal path instead of losing the editor entirely. +it.effect("falls back to the Linux file manager when WSL lacks the Explorer bridge", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const editors = yield* launcher.resolveAvailableEditors(); + const kind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { editors, kind }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.equal(result.kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "falls back to opening the containing directory for WSL paths Explorer cannot select", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: '/home/t3/work "quoted"/clip.mp4', + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + // Explorer's raw switch cannot express a double quote, so the launch + // opens the parent directory instead of misparsing a /select argument. + assert.ok(spawned); + assert.equal(spawned.command, "explorer.exe"); + assert.deepEqual(spawned.args, ['\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work "quoted"']); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals by opening the containing directory on Linux", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + const spawned = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager without a graphical session", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("advertises a Linux file manager when a directory handler is installed", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let probe: ChildProcess.StandardCommand | undefined; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + probe = command; + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), true); + assert.ok(probe); + assert.equal(probe.command, "xdg-mime"); + assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// `xdg-open` with a display variable but no `inode/directory` handler exits +// nonzero after the launch has already detached: without this gate the server +// advertises a reveal that is a silent no-op. +it.effect("does not advertise a Linux file manager without a directory handler", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when the handler query fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => + command.command === "xdg-mime" + ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } + : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// The handler probe carries its own timeout because the editor scan's outer +// timeout in server.getConfig degrades to an EMPTY editor list: a wedged +// xdg-mime must cost only the file manager, never the other editors. Runs on +// the live clock so the probe's real timeout fires. +it.live("a stalled handler probe drops only the file manager", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime", "code"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stall: true } : undefined), + }), + ), + ); + + assert.equal(editors.includes("vscode"), true); + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when xdg-mime is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("discovers editors through the service API", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 8ec928f26fc3..96e6470311f4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -15,6 +15,7 @@ import { ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, type EditorId, + type FileManagerRevealKind, type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -29,6 +30,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -99,6 +101,8 @@ const BrowserLaunchEnvConfig = Config.all({ SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), container: Config.string("container").pipe(Config.option), + DISPLAY: Config.string("DISPLAY").pipe(Config.option), + WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), }).pipe(Config.map(compactEnv)); const CommandLookupEnvConfig = Config.all({ @@ -193,7 +197,13 @@ function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; } -function shouldUseWindowsBrowserFromWsl( +// File reveals from WSL resolve PowerShell through the interop PATH rather +// than the fixed /mnt/c mount: the automount root is configurable, and a +// PATH-resolved command keeps the advertised capability aligned with the +// availability check `launchEditor` performs before spawning. +const WSL_POWERSHELL_COMMAND = "powershell.exe"; + +function shouldUseWindowsHostFromWsl( platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {}, ): boolean { @@ -223,17 +233,163 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa }; } -function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { +function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean { + return [env.DISPLAY, env.WAYLAND_DISPLAY].some( + (value) => value !== undefined && value.trim().length > 0, + ); +} + +function fileManagerCommandForPlatform( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): string | undefined { switch (platform) { case "darwin": return "open"; case "win32": return "explorer"; default: - return "xdg-open"; + if (shouldUseWindowsHostFromWsl(platform, env)) { + return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined; + } + return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined; } } +// A graphical session variable plus an executable `xdg-open` does not prove +// that opening a directory does anything: without an `inode/directory` MIME +// handler, `xdg-open` exits nonzero after the launcher has already detached, +// so the client would see a silent no-op. Require the handler before +// advertising the file manager on Linux. +// +// The probe carries its own timeout well inside the scan timeout +// `server.getConfig` applies to editor discovery: that outer timeout degrades +// to an empty editor list, so a hung `xdg-mime` (broken D-Bus or desktop +// session) must cost only the file manager, not every discovered editor. +const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; + +const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( + function* ( + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable("xdg-mime", { env }))) { + return false; + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .spawn( + ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], { + stdin: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.flatMap((handle) => + Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], { + concurrency: "unbounded", + }), + ), + Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0), + Effect.scoped, + Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT), + Effect.orElseSucceed(() => false), + ); + }, +); + +const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")( + function* ( + command: string, + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable(command, { env }))) { + return false; + } + return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env)); + }, +); + +// The file-manager command a launch can actually run, not just the platform +// preference. WSL hosts prefer the Windows Explorer bridge, but interop can +// exist without `explorer.exe` on PATH (appendWindowsPath=false) or without a +// distro name while WSLg still provides a working Linux file manager, so they +// keep the `xdg-open` fallback instead of losing the editor entirely. +const resolveUsableFileManagerCommand = Effect.fn( + "externalLauncher.resolveUsableFileManagerCommand", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + string | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + const command = fileManagerCommandForPlatform(platform, env); + if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) { + return command; + } + if ( + shouldUseWindowsHostFromWsl(platform, env) && + hasGraphicalLinuxSession(env) && + (yield* isUsableFileManagerCommand("xdg-open", env)) + ) { + return "xdg-open"; + } + return undefined; +}); + +// Reveal on Windows and WSL runs through PowerShell (see +// resolveFileManagerRevealLaunch), not the `explorer` command that gates the +// file-manager editor itself, so the capability must probe the executables the +// reveal actually spawns. Callers gate on file-manager availability first; +// the Linux "files" kind relies on that gate for the directory-handler probe, +// while the WSL fallback re-probes because its availability may have come +// from the Explorer bridge instead. +const fileManagerRevealKindForPlatform = Effect.fn( + "externalLauncher.fileManagerRevealKindForPlatform", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + FileManagerRevealKind | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") return "finder"; + if (platform === "win32") { + return (yield* isCommandAvailable(resolvePowerShellPath(env), { env })) + ? "file-explorer" + : undefined; + } + if (shouldUseWindowsHostFromWsl(platform, env)) { + if ( + env.WSL_DISTRO_NAME?.trim() && + (yield* isCommandAvailable("explorer.exe", { env })) && + (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) + ) { + return "file-explorer"; + } + return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) + ? "files" + : undefined; + } + return hasGraphicalLinuxSession(env) ? "files" : undefined; +}); + +function resolveWslFileManagerPath(target: string, distroName: string): string { + const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); + return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`; +} + function buildBrowserLaunch( target: string, platform: NodeJS.Platform, @@ -251,7 +407,7 @@ function buildBrowserLaunch( return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env)); } - if (shouldUseWindowsBrowserFromWsl(platform, env)) { + if (shouldUseWindowsHostFromWsl(platform, env)) { return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath()); } @@ -265,13 +421,16 @@ function buildBrowserLaunch( const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { +): Effect.fn.Return< + ReadonlyArray, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { - const command = fileManagerCommandForPlatform(platform); - if (yield* isCommandAvailable(command, { env })) { + if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { available.push(editor.id); } continue; @@ -296,10 +455,18 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* buildAvailableEditors(platform, env); }); +const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( + function* () { + const platform = yield* HostProcessPlatform; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; + return yield* fileManagerRevealKindForPlatform(platform, env); + }, +); + // Editor discovery walks PATH for every known editor and runs for every // client connect (the server config embeds the available editors). Memoize // the discovered set for a bounded window so repeat connects skip even the @@ -329,6 +496,14 @@ export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect>; + /** + * Reveal kind for the host, or undefined when the executable a reveal + * actually spawns is unavailable. Only meaningful when + * `resolveAvailableEditors` includes "file-manager": on Linux that + * availability check also carries the directory-handler probe this + * capability relies on. + */ + readonly resolveFileManagerRevealKind: () => Effect.Effect; /** Launch a URL target in the default browser. */ readonly launchBrowser: (target: string) => Effect.Effect; /** @@ -346,9 +521,13 @@ export class ExternalLauncher extends Context.Service< const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, -): Effect.fn.Return { +): Effect.fn.Return< + EditorLaunch, + ExternalLauncherError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, @@ -376,14 +555,126 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } + const command = yield* resolveUsableFileManagerCommand(platform, env); + if (command === undefined) { + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); + } + + if (input.reveal === true) { + return yield* resolveFileManagerRevealLaunch(input.cwd, platform, env, command); + } + return { editor: editorDef.id, target: input.cwd, - command: fileManagerCommandForPlatform(platform), - args: [input.cwd], + command, + args: + command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined + ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] + : [input.cwd], }; }); +/** + * PowerShell source that launches File Explorer with its raw selection + * switch. Explorer's contract is the single argument `/select,""` with + * only the path quoted; Node's default spawn quoting wraps the whole argument + * when the path has spaces and Explorer misparses it, silently opening a + * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1 + * reaches the child's command line verbatim, preserving the raw switch. + * + * Exported so the Windows smoke test can drive the identical source through a + * real PowerShell against a recording stub instead of Explorer. + */ +export function buildFileExplorerRevealPowerShellSource( + explorerCommand: string, + target: string, +): string { + return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`; +} + +function fileExplorerRevealLaunch( + target: string, + explorerTarget: string, + powershellCommand: string, +): EditorLaunch { + return { + editor: "file-manager", + target, + command: powershellCommand, + args: [ + ...POWERSHELL_ARGUMENTS_PREFIX, + encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)), + ], + }; +} + +const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch")(function* ( + target: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + // The command resolveUsableFileManagerCommand picked; a WSL host that fell + // back to the Linux file manager must reveal through it as well. + command: string, +): Effect.fn.Return< + EditorLaunch, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") { + return { editor: "file-manager", target, command: "open", args: ["-R", target] }; + } + + if (platform === "win32") { + return fileExplorerRevealLaunch(target, target, resolvePowerShellPath(env)); + } + + if ( + command === "explorer.exe" && + shouldUseWindowsHostFromWsl(platform, env) && + env.WSL_DISTRO_NAME !== undefined + ) { + const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME); + if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) { + // Explorer's raw switch cannot express a double quote, and unlike + // Windows paths a WSL path may legally contain one: open the containing + // directory in File Explorer instead, matching the advertised + // "file-explorer" kind. + if (explorerTarget.includes('"')) { + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + return fileExplorerRevealLaunch(target, explorerTarget, WSL_POWERSHELL_COMMAND); + } + // Without interop PowerShell the capability advertised the Linux "files" + // kind when it advertised anything at all, so the reveal must open the + // Linux file manager the label promised, not File Explorer. + if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) { + const path = yield* Path.Path; + return { editor: "file-manager", target, command: "xdg-open", args: [path.dirname(target)] }; + } + // Nothing was advertised here; open the parent in File Explorer as the + // best remaining effort for a stale client. + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + + // Linux file managers have no portable "select this file" flag, so open + // the containing directory instead. + const path = yield* Path.Path; + return { editor: "file-manager", target, command, args: [path.dirname(target)] }; +}); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -476,7 +767,9 @@ export const make = Effect.gen(function* () { if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { return entry.value.editors; } - const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); + const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); yield* Ref.set( editorDiscoveryCache, Option.some({ @@ -489,18 +782,18 @@ export const make = Effect.gen(function* () { return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, + resolveFileManagerRevealKind: () => + provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), launchEditor: (input) => provideCommandResolutionServices( - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - ), - ), + Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess), + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 7448ced247b5..c610781ea9be 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -91,6 +91,21 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("uses a saved project favicon outside the workspace", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const pictures = yield* makeTempDir; + yield* writeTextFile(pictures, "custom.png", "image"); + const externalPath = path.join(pictures, "custom.png"); + + const resolved = yield* resolver.resolvePath(cwd, externalPath); + + expect(resolved).toBe(externalPath); + }), + ); + it.effect("falls back when a saved override is missing from a checkout", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 458954daed4b..9d9a5bddc791 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -137,22 +137,25 @@ export const make = Effect.gen(function* () { const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, + candidateScope: "workspace" | "filesystem", ): Effect.fn.Return { for (const relativePath of relativeCandidates) { - const candidate = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: projectCwd, - relativePath, - }) - .pipe( - Effect.map(Option.some), - Effect.catchTags({ - WorkspacePathOutsideRootError: () => - Effect.succeed( - Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), - ), - }), - ); + const candidate = yield* ( + candidateScope === "filesystem" && path.isAbsolute(relativePath) + ? Effect.succeed({ absolutePath: relativePath, relativePath }) + : workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + ).pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => + Effect.succeed( + Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), + ), + }), + ); if (Option.isNone(candidate)) { continue; } @@ -191,7 +194,7 @@ export const make = Effect.gen(function* () { // A grouped project's saved path can be absent from one checkout. Use it // where it exists and retain automatic discovery for the other checkouts. if (faviconPath !== undefined) { - const existing = yield* findExistingFile(projectCwd, [faviconPath]); + const existing = yield* findExistingFile(projectCwd, [faviconPath], "filesystem"); if (existing) { return existing; } @@ -200,14 +203,18 @@ export const make = Effect.gen(function* () { // A t3.json iconPath takes precedence over the well-known locations. const projectFile = yield* projectFileLoader.load(projectCwd); if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { - const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]); + const existing = yield* findExistingFile( + projectCwd, + [projectFile.value.iconPath], + "workspace", + ); if (existing) { return existing; } } for (const candidate of FAVICON_CANDIDATES) { - const existing = yield* findExistingFile(projectCwd, [candidate]); + const existing = yield* findExistingFile(projectCwd, [candidate], "workspace"); if (existing) { return existing; } @@ -251,7 +258,7 @@ export const make = Effect.gen(function* () { if (!href) { continue; } - const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); + const existing = yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace"); if (existing) { return existing; } diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..0409b7c691b1 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -36,6 +36,7 @@ import { } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -87,6 +88,7 @@ export type ClaudeDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ServerConfig @@ -125,6 +127,7 @@ export const ClaudeDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const modelManifest = yield* ModelManifest.ModelManifest; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -163,13 +166,24 @@ export const ClaudeDriver: ProviderDriver = { }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); - const checkProvider = checkClaudeProviderStatus( - effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), - processEnv, - cwd, - ).pipe( - Effect.map(stampIdentity), + // Kick the TTL-gated manifest refresh in the background and classify + // with the in-memory manifest, so a slow or hung fetch never delays the + // provider check. A refresh that lands mid-probe applies on the next one. + const checkProvider = modelManifest.refreshInBackground.pipe( + Effect.andThen( + Effect.zipWith( + checkClaudeProviderStatus( + effectiveConfig, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + processEnv, + cwd, + ), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + { concurrent: true }, + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -182,7 +196,12 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + Effect.zipWith( + makePendingClaudeProvider(settings.provider), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 15d7a1ff0216..80a848c7ce3b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -39,6 +39,7 @@ import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; @@ -78,6 +79,7 @@ export type CodexDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ServerConfig @@ -119,6 +121,7 @@ export const CodexDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const modelManifest = yield* ModelManifest.ModelManifest; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -166,8 +169,19 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( - Effect.map(stampIdentity), + // Kick the TTL-gated manifest refresh in the background and classify + // with the in-memory manifest, so a slow or hung fetch never delays the + // provider check. A refresh that lands mid-probe applies on the next one. + const checkProvider = modelManifest.refreshInBackground.pipe( + Effect.andThen( + Effect.zipWith( + checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + { concurrent: true }, + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); @@ -177,7 +191,12 @@ export const CodexDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingCodexProvider(settings.provider).pipe(Effect.map(stampIdentity)), + Effect.zipWith( + makePendingCodexProvider(settings.provider), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index db1151258371..947f6607fcab 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -61,6 +61,7 @@ class FakeClaudeQuery implements AsyncIterable { public readonly setPermissionModeCalls: Array = []; public readonly setMaxThinkingTokensCalls: Array = []; public closeCalls = 0; + public closeError: unknown | undefined; emit(message: SDKMessage): void { if (this.done) { @@ -118,6 +119,9 @@ class FakeClaudeQuery implements AsyncIterable { readonly close = (): void => { this.closeCalls += 1; + if (this.closeError !== undefined) { + throw this.closeError; + } this.finish(); }; @@ -450,6 +454,25 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("passes the configured auto-compaction window to Claude", () => { + const harness = makeHarness({ claudeConfig: { autoCompactWindow: "300000" } }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const options = harness.getLastCreateQueryInput()?.options; + assert.deepEqual(options?.settings, { autoCompactWindow: 300000 }); + assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -767,6 +790,39 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps compact commands intact when ultrathink is selected", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const modelSelection = createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-4-6", + [{ id: "effort", value: "ultrathink" }], + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/compact", + attachments: [], + modelSelection, + }); + + const promptText = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.equal(promptText, "/compact"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("embeds image attachments in Claude user messages", () => { const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); const harness = makeHarness({ @@ -1749,6 +1805,94 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("stopSession settles live tasks and closes the provider session", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + // Wait for the three task.* runtime events to prove the lifecycle + // handlers processed the emissions (no wall-clock sleeps under the + // test clock). + const taskEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn agents", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-live", + description: "Agent A", + task_type: "local_agent", + uuid: "task-live-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-settled", + description: "Agent B", + task_type: "local_agent", + uuid: "task-settled-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-settled", + status: "completed", + output_file: "/tmp/task-settled.jsonl", + summary: "done", + uuid: "task-settled-done-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + + yield* Fiber.join(taskEventsFiber); + + const stoppedTaskEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.stopSession(session.threadId); + + // Closing the session is the hard stop because SDK interrupt can leave + // resumed background work alive. + assert.equal(harness.query.closeCalls, 1); + + const sessions = yield* adapter.listSessions(); + assert.equal(sessions.length, 0); + + const stoppedTaskEvents = Array.from(yield* Fiber.join(stoppedTaskEventFiber)); + assert.equal(stoppedTaskEvents.length, 1); + const stoppedTaskEvent = stoppedTaskEvents[0]; + assert.equal(stoppedTaskEvent?.type, "task.completed"); + if (stoppedTaskEvent?.type === "task.completed") { + assert.equal(String(stoppedTaskEvent.payload.taskId), "task-live"); + assert.equal(stoppedTaskEvent.payload.status, "stopped"); + assert.equal(stoppedTaskEvent.payload.taskType, "local_agent"); + assert.equal(stoppedTaskEvent.payload.title, "Agent A"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("replays an interrupt acknowledged before the SDK begins processing the turn", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1812,6 +1956,172 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps the session available when process close fails", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + harness.query.closeError = new Error("close failed"); + + const result = yield* adapter.stopSession(session.threadId).pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "ProviderAdapterProcessError"); + } + assert.equal(harness.query.closeCalls, 1); + assert.equal(yield* adapter.hasSession(session.threadId), true); + assert.equal((yield* adapter.listSessions())[0]?.status, "ready"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("stopAll attempts every session when one process close fails", () => { + const queries: FakeClaudeQuery[] = []; + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + const query = new FakeClaudeQuery(); + queries.push(query); + return query; + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const firstQuery = queries[0]; + if (!firstQuery) { + return; + } + firstQuery.closeError = new Error("close failed"); + + const result = yield* adapter.stopAll().pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.equal(queries[0]?.closeCalls, 1); + assert.equal(queries[1]?.closeCalls, 1); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(yield* adapter.hasSession(RESUME_THREAD_ID), false); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + + it.effect("keeps a resumed replacement session during slow stop cleanup", () => { + const queries: FakeClaudeQuery[] = []; + let signalUsageStarted: () => void = () => undefined; + const usageStarted = new Promise((resolve) => { + signalUsageStarted = resolve; + }); + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + const query = new FakeClaudeQuery(); + if (queries.length === 0) { + Object.assign(query, { + getContextUsage: async () => { + signalUsageStarted(); + return await new Promise(() => undefined); + }, + }); + } + queries.push(query); + return query; + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + const firstSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: firstSession.threadId, + input: "hello", + attachments: [], + }); + + const interruptFiber = yield* adapter + .stopSession(firstSession.threadId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => usageStarted); + assert.equal(queries[0]?.closeCalls, 1); + + const replacement = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + resumeCursor: firstSession.resumeCursor, + }); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(interruptFiber); + + const activeSessions = yield* adapter.listSessions(); + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.equal(queries.length, 2); + assert.equal(queries[1]?.closeCalls, 0); + assert.equal(activeSessions.length, 1); + assert.deepEqual(activeSessions[0]?.resumeCursor, replacement.resumeCursor); + assert.deepEqual( + runtimeEvents + .filter((event) => event.type.startsWith("session.")) + .map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "session.started", + "session.configured", + "session.state.changed", + ], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("workflow member coalescing: identical snapshots suppress, changes emit", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -2138,6 +2448,84 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("a subagent snapshot that beats task_started still wins over the seed", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const taskEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type.startsWith("task.")), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "effort", value: "max" }], + ), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn an agent", + attachments: [], + }); + + // The subagent streams its first assistant snapshot before the task is + // registered, so there is no agent to refine yet. + harness.query.emit({ + type: "assistant", + parent_tool_use_id: "toolu_agent_early", + message: { + model: "claude-sonnet-5[1m]", + content: [], + }, + uuid: "early-snapshot-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-early", + description: "Agent E", + task_type: "local_agent", + tool_use_id: "toolu_agent_early", + uuid: "task-early-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-early", + description: "Agent E", + usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 }, + uuid: "task-early-progress-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + + const taskEvents = Array.from(yield* Fiber.join(taskEventsFiber)); + const started = taskEvents[0]; + assert.equal(started?.type, "task.started"); + if (started?.type === "task.started") { + assert.equal(started.payload.model, "claude-sonnet-5[1m]"); + assert.equal(started.payload.effort, "max"); + } + const progress = taskEvents[1]; + assert.equal(progress?.type, "task.progress"); + if (progress?.type === "task.progress") { + assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -5355,6 +5743,62 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("routes Claude resume compaction through the shared user-input UI", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { resume: "550e8400-e29b-41d4-a716-446655440000" }, + runtimeMode: "full-access", + }); + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const onUserDialog = harness.getLastCreateQueryInput()?.options.onUserDialog; + assert.equal(typeof onUserDialog, "function"); + if (!onUserDialog) return; + + const dialogPromise = onUserDialog( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, + }, + { signal: new AbortController().signal }, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") return; + const question = requested.value.payload.questions[0]; + assert.equal(question?.header, "Resume session"); + assert.match(question?.question ?? "", /2h 25m/); + assert.match(question?.question ?? "", /275,123 tokens/); + assert.deepEqual( + question?.options.map((option) => option.label), + ["Compact and continue", "Keep full history", "Don't ask again"], + ); + if (!question || !requested.value.requestId) return; + + yield* adapter.respondToUserInput( + session.threadId, + ApprovalRequestId.make(requested.value.requestId), + { [question.id]: "Compact and continue" }, + ); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag === "Some") assert.equal(resolved.value.type, "user-input.resolved"); + assert.deepEqual(yield* Effect.promise(() => dialogPromise), { + behavior: "completed", + result: "compact", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -5644,6 +6088,73 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("denies AskUserQuestion when the signal aborted before the listener registered", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + // Abort before the call so the adapter's listener registration can + // never observe the abort event, only the recheck can. + const controller = new AbortController(); + controller.abort(); + const permissionPromise = canUseTool( + "AskUserQuestion", + { + questions: [ + { + question: "Continue?", + header: "Continue", + options: [{ label: "Yes", description: "Proceed" }], + multiSelect: false, + }, + ], + }, + { + signal: controller.signal, + toolUseID: "tool-ask-pre-aborted", + }, + ); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.deepEqual(permissionResult, { + behavior: "deny", + message: "User cancelled tool execution.", + } satisfies PermissionResult); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + ["user-input.requested", "user-input.resolved"], + ); + const resolvedEvent = runtimeEvents[1]; + if (resolvedEvent?.type === "user-input.resolved") { + assert.deepEqual(resolvedEvent.payload.answers, {}); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("stopping a session settles pending user-input waits", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index bee3a8b19394..f433feca87e3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -59,6 +59,10 @@ import { resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import * as Cache from "effect/Cache"; +import { + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + formatClaudeResumeCompactionQuestion, +} from "@t3tools/shared/claudeCompaction"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -258,6 +262,32 @@ interface ClaudeTaskAgentState { agentKind: "agent" | "background" | undefined; } +/** + * How many racing snapshot models to buffer per session. A snapshot whose + * task_started never arrives would otherwise pin its entry for the session's + * lifetime; oldest entries evict first. + */ +const PENDING_TASK_MODEL_CAP = 64; + +/** + * Buffers a subagent snapshot's authoritative model under its + * parent_tool_use_id, for snapshots that beat their task_started to the + * stream. task_started consumes the entry when it registers the task. + */ +function rememberPendingTaskModel( + pending: Map, + parentToolUseId: string, + model: string, +): void { + pending.set(parentToolUseId, model); + if (pending.size > PENDING_TASK_MODEL_CAP) { + const oldest = pending.keys().next(); + if (!oldest.done) { + pending.delete(oldest.value); + } + } +} + interface ClaudeSessionContext { session: ProviderSession; /** Lifetime turns already represented by the cursor used to start this SDK session. */ @@ -283,6 +313,12 @@ interface ClaudeSessionContext { readonly claudeTasks: Map; readonly taskAgents: Map; readonly externalAgentLaunches: Map; + /** + * Authoritative subagent models from assistant snapshots that arrived before + * their task_started registered the task, keyed by parent_tool_use_id. + * Written through `rememberPendingTaskModel`, consumed by task_started. + */ + readonly pendingTaskModels: Map; /** * Last emitted workflow-member fingerprint per member slot. A coordinator * task_progress repeats the FULL member array every tick; without a @@ -538,6 +574,7 @@ function makeClaudeTokenUsageSnapshot(input: { readonly totalProcessedTokens?: number; readonly lastUsedTokens?: number; readonly compactsAutomatically?: boolean; + readonly autoCompactThreshold?: number; }): ThreadTokenUsageSnapshot | undefined { const activeTokens = finiteNonNegativeInteger(input.activeTokens); if (activeTokens === undefined || activeTokens <= 0) { @@ -565,6 +602,9 @@ function makeClaudeTokenUsageSnapshot(input: { ...(input.compactsAutomatically !== undefined ? { compactsAutomatically: input.compactsAutomatically } : {}), + ...(input.autoCompactThreshold !== undefined + ? { autoCompactThreshold: input.autoCompactThreshold } + : {}), }; } @@ -599,11 +639,13 @@ function normalizeClaudeContextUsageApiSnapshot( value: SDKControlGetContextUsageResponse, totalProcessedTokens?: number, ): ThreadTokenUsageSnapshot | undefined { + const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold); return makeClaudeTokenUsageSnapshot({ activeTokens: value.totalTokens, contextWindow: value.maxTokens, ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), compactsAutomatically: value.isAutoCompactEnabled, + ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}), }); } @@ -2245,13 +2287,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } catch { return undefined; } - }); - if (!usage) { + }).pipe(Effect.timeoutOption("1 second")); + if (Option.isNone(usage) || !usage.value) { return undefined; } - context.lastKnownContextWindow = usage.maxTokens; - return normalizeClaudeContextUsageApiSnapshot(usage, totalProcessedTokens); + context.lastKnownContextWindow = usage.value.maxTokens; + return normalizeClaudeContextUsageApiSnapshot(usage.value, totalProcessedTokens); }); const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( @@ -3039,8 +3081,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId); const snapshotModel = trimmedString(message.message.model); const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined; - if (owningAgent && snapshotModel) { - owningAgent.model = snapshotModel; + if (snapshotModel) { + if (owningAgent) { + owningAgent.model = snapshotModel; + } else { + // The snapshot beat its task_started (or its tool_use_id was never + // recorded): hold the model until the task registers. + rememberPendingTaskModel( + context.pendingTaskModels, + assistantParentToolUseId, + snapshotModel, + ); + } } return; } @@ -3367,10 +3419,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // override. External Codex tasks only advertise values explicitly // present in their CLI command; inheriting Claude's selection would // mislabel them. Native snapshots can later refine their API model. + // Subagent assistant snapshots refine model with the authoritative API + // id: one that already arrived is buffered and outranks the seed here, + // later ones refine the record in place. const launchInput = launchingTool?.input; + const toolUseId = message.tool_use_id; + const bufferedModel = toolUseId ? context.pendingTaskModels.get(toolUseId) : undefined; + if (toolUseId) { + context.pendingTaskModels.delete(toolUseId); + } const model = externalAgentLaunch ? externalAgentLaunch.model - : (trimmedString(launchInput?.model) ?? + : (bufferedModel ?? + trimmedString(launchInput?.model) ?? trimmedString(context.session.model ?? undefined)); const rawLaunchEffort = launchInput?.effort; const effort = externalAgentLaunch @@ -3891,8 +3952,42 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { if (context.stopped) return; + // Schedule process termination before any cleanup that can wait on the + // provider. The SDK closes stdin, then escalates from SIGTERM to SIGKILL. + yield* Effect.try({ + try: () => context.query.close(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: "Failed to close Claude runtime query.", + cause, + }), + }); + context.stopped = true; + for (const taskId of Array.from(context.liveTaskIds)) { + if (!context.liveTaskIds.delete(taskId)) { + continue; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + payload: { + taskId: RuntimeTaskId.make(taskId), + status: "stopped", + ...taskLinkageFor(context.taskAgents, taskId), + }, + providerRefs: nativeProviderRefs(context), + }); + } + for (const [requestId, pending] of context.pendingApprovals) { yield* Deferred.succeed(pending.decision, "cancel"); const stamp = yield* makeEventStamp(); @@ -3931,26 +4026,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* Fiber.interrupt(streamFiber); } - yield* Effect.try({ - try: () => context.query.close(), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: context.session.threadId, - detail: "Failed to close Claude runtime query.", - cause, - }), - }).pipe( - Effect.catch((error) => - emitRuntimeError(context, "Failed to close Claude runtime query.", { - errorTag: error._tag, - provider: error.provider, - threadId: error.threadId, - detail: error.detail, - }), - ), - ); - const updatedAt = yield* nowIso; context.session = { ...context.session, @@ -3959,7 +4034,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( updatedAt, }; - if (options?.emitExitEvent !== false) { + if (options?.emitExitEvent !== false && sessions.get(context.session.threadId) === context) { const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.exited", @@ -3975,7 +4050,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - sessions.delete(context.session.threadId); + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } }); const requireSession = ( @@ -4020,16 +4097,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); yield* stopSessionInternal(existingContext, { emitExitEvent: false, - }).pipe( - // Replacement cleanup is best-effort: never block the new session on - // either typed failures or unexpected defects from tearing down the old one. - Effect.catchCause((cause) => - Effect.logWarning("claude.session.replace.stop-failed", { - threadId: input.threadId, - cause, - }), - ), - ); + }); } const startedAt = yield* nowIso; @@ -4059,6 +4127,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const claudeTasks = new Map(); const taskAgents = new Map(); const externalAgentLaunches = new Map(); + const pendingTaskModels = new Map(); const workflowMemberFingerprints = new Map(); const liveTaskIds = new Set(); @@ -4154,6 +4223,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // The signal may have aborted during the awaited event emissions + // above, before the listener existed; settle now so the dialog + // cannot hang with a lingering pending question. + if (callbackOptions.signal.aborted) { + yield* settleAsAborted; + } // Block until the user provides answers. const answers = yield* Deferred.await(answersDeferred); @@ -4202,6 +4277,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; }); + const handleResumeDialog = Effect.fn("handleResumeDialog")(function* ( + request: Parameters>[0], + callbackOptions: Parameters>[1], + ) { + if (request.dialogKind !== "resume_return") { + return { behavior: "cancelled" as const }; + } + + const context = yield* Ref.get(contextRef); + if (!context) { + return { behavior: "cancelled" as const }; + } + + // The question copy lives in @t3tools/shared/claudeCompaction because + // the web client recognizes this exact text (and the "never" answer) + // to mirror a permanent dismissal. + const question = formatClaudeResumeCompactionQuestion({ + ageMinutes: finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0, + estimatedTokens: finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0, + }); + const result = yield* handleAskUserQuestion( + context, + { + questions: [ + { + header: "Resume session", + question, + options: [ + { + label: "Compact and continue", + description: "Resume with a summary and use fewer tokens.", + }, + { + label: "Keep full history", + description: "Resume without changing the conversation.", + }, + { + label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + description: "Keep full history and skip future resume prompts.", + }, + ], + multiSelect: false, + }, + ], + }, + { + signal: callbackOptions.signal, + ...(request.toolUseID ? { toolUseID: request.toolUseID } : {}), + }, + ); + + if (result.behavior !== "allow") { + return { behavior: "cancelled" as const }; + } + + const answers = result.updatedInput.answers; + const selection = + answers && typeof answers === "object" && !Array.isArray(answers) + ? (answers as Record)[question] + : undefined; + const action = + selection === "Compact and continue" + ? "compact" + : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER + ? "never" + : "continue"; + + return { behavior: "completed" as const, result: action }; + }); + const canUseToolEffect = Effect.fn("canUseTool")(function* ( toolName: Parameters[0], toolInput: Parameters[1], @@ -4307,6 +4452,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // Same late-listener race as handleAskUserQuestion: the signal may + // have aborted while the request event emissions were awaited. + if (callbackOptions.signal.aborted) { + onAbort(); + } const decision = yield* Deferred.await(decisionDeferred); pendingApprovals.delete(requestId); @@ -4362,6 +4512,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + const onUserDialog: NonNullable = ( + request, + callbackOptions, + ) => runPromise(handleResumeDialog(request, callbackOptions)); const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; @@ -4397,6 +4551,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), + ...(claudeSettings.autoCompactWindow + ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) } + : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); // The attachments dir grant lets the agent Read/copy pasted images at @@ -4435,6 +4592,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( includePartialMessages: true, promptSuggestions: claudeSettings.promptSuggestions, canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], env: claudeEnvironment, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), @@ -4532,6 +4691,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( claudeTasks, taskAgents, externalAgentLaunches, + pendingTaskModels, workflowMemberFingerprints, liveTaskIds, turnState: undefined, @@ -5045,25 +5205,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return context !== undefined && !context.stopped; }); - const stopAll: ClaudeAdapterShape["stopAll"] = () => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: true, - }), - { discard: true }, + const stopSessions = Effect.fn("stopSessions")(function* ( + contexts: ReadonlyArray, + emitExitEvent: boolean, + ) { + const results = yield* Effect.forEach(contexts, (context) => + stopSessionInternal(context, { emitExitEvent }).pipe(Effect.result), ); + for (const result of results) { + if (result._tag === "Failure") { + return yield* Effect.fail(result.failure); + } + } + }); + + const stopAll: ClaudeAdapterShape["stopAll"] = () => + stopSessions(Array.from(sessions.values()), true); + yield* Effect.addFinalizer(() => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: false, - }), - { discard: true }, - ).pipe( + stopSessions(Array.from(sessions.values()), false).pipe( Effect.catch((cause) => Effect.logError("Failed to emit Claude session shutdown event.", { cause }), ), diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 040e63b80229..7e5fa2611f0f 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -9,27 +9,11 @@ import * as Schema from "effect/Schema"; import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, - isLegacyClaudeModel, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); -it("keeps only the Claude 5 family out of legacy models", () => { - assert.deepStrictEqual( - ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ - model, - isLegacyClaudeModel(model), - ]), - [ - ["claude-fable-5", false], - ["claude-opus-5", false], - ["claude-sonnet-5", false], - ["claude-opus-4-8", true], - ], - ); -}); - it("isolates Claude capability probes without dropping workspace setting sources", () => { const abortController = new AbortController(); const options = buildClaudeCapabilitiesProbeQueryOptions({ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b08b5db68ee0..f815ac75be34 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -56,12 +56,6 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; -const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]); - -export function isLegacyClaudeModel(model: string): boolean { - return !CURRENT_CLAUDE_MODELS.has(model); -} - const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ { slug: "claude-fable-5", @@ -327,9 +321,9 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ }, ]; -const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG.map((model) => - isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, -); +// Legacy classification happens at the driver boundary via `applyModelManifest`, +// so the catalog itself carries no `isLegacy` flags. +const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; @@ -862,7 +856,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( status: "error", auth: { status: "unknown" }, message: isCommandMissingCause(error) - ? "Claude Agent CLI (`claude`) is not installed or not on PATH." + ? "Claude Agent CLI (`claude`) was not found on PATH." : "Failed to execute Claude Agent CLI health check.", }, }); @@ -927,7 +921,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = capabilities?.slashCommands ?? []; + const slashCommands = [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, + ...(capabilities?.slashCommands ?? []), + ]; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index d85a378cd186..ea53c8ef88cf 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -105,6 +105,9 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { ); public readonly deleteThreadImpl = vi.fn((): Promise => Promise.resolve(undefined)); + public readonly uploadFeedbackImpl = vi.fn((_reason?: string) => + Promise.resolve({ threadId: "provider-thread-1" }), + ); public readonly respondToRequestImpl = vi.fn( (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise => @@ -145,6 +148,9 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { } deleteThread = Effect.promise(() => this.deleteThreadImpl()); + uploadFeedback(reason?: string) { + return Effect.promise(() => this.uploadFeedbackImpl(reason)); + } respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) { return Effect.promise(() => this.respondToRequestImpl(requestId, decision)); @@ -419,6 +425,42 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("uploads feedback for the active Codex thread", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-feedback"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + + const result = yield* adapter.uploadFeedback({ + threadId, + reason: "The agent stopped early.", + }); + + NodeAssert.deepStrictEqual(result, { feedbackId: "provider-thread-1" }); + NodeAssert.deepStrictEqual(runtime.uploadFeedbackImpl.mock.calls, [ + ["The agent stopped early."], + ]); + }), + ); + + it.effect("rejects feedback for an unknown Codex thread", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const result = yield* adapter + .uploadFeedback({ threadId: asThreadId("thread-feedback-missing") }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); + }), + ); + it.effect("maps codex model options before sending a turn", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; @@ -604,6 +646,67 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("does not reactivate an idle child after a parent interaction", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( + Effect.forkChild, + ); + + const childEvent = (id: string, method: string, payload: Record) => ({ + id: asEventId(id), + kind: "notification" as const, + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload, + }); + + yield* runtime.emit( + childEvent("evt-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + }), + ); + yield* runtime.emit( + childEvent("evt-child-idle", "collabAgent/turnCompleted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + turn: { status: "completed" }, + }), + ); + yield* runtime.emit( + childEvent("evt-child-interacted", "collabAgent/activity", { + agentThreadId: "child-1", + agentPath: "/root/audit", + activityKind: "interacted", + }), + ); + yield* runtime.emit( + childEvent("evt-other-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-2", + agentPath: "/root/other", + }), + ); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepStrictEqual( + events.map((event) => + event.type === "task.updated" + ? { taskId: event.payload.taskId, status: event.payload.status } + : { type: event.type }, + ), + [ + { taskId: "child-1", status: "running" }, + { taskId: "child-1", status: "idle" }, + { taskId: "child-2", status: "running" }, + ], + ); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); @@ -705,6 +808,66 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("preserves failed and declined outcomes on completed tool items", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const items = [ + { + type: "commandExecution", + id: "failed-command", + command: "vp test run", + commandActions: [], + cwd: "/tmp", + exitCode: 1, + status: "failed", + }, + { + type: "mcpToolCall", + id: "failed-mcp", + server: "simulator", + tool: "build", + arguments: {}, + error: { message: "Build failed" }, + status: "failed", + }, + { + type: "fileChange", + id: "declined-change", + changes: [], + status: "declined", + }, + ] as const; + + for (const item of items) { + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId(`evt-${item.id}`), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId(item.id), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item, + }, + }); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + NodeAssert.equal(firstEvent.value.payload.status, item.status); + } + }), + ); + it.effect("maps completed plan items to canonical proposed-plan completion events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); @@ -992,6 +1155,79 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps MCP elicitation requests into app access approvals", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-elicitation"), + kind: "request", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-08-24T00:00:00.000Z", + method: "mcpServer/elicitation/request", + requestKind: "mcp-elicitation", + requestId: ApprovalRequestId.make("req-safari"), + turnId: asTurnId("turn-1"), + payload: { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { type: "object", properties: {} }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.opened") { + return; + } + NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval"); + NodeAssert.equal(firstEvent.value.payload.appName, "Safari"); + NodeAssert.equal(firstEvent.value.payload.detail, "Allow ChatGPT to use Safari?"); + NodeAssert.deepStrictEqual(firstEvent.value.payload.options, [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Always allow this session" }, + { decision: "acceptAlways", label: "Always allow" }, + { decision: "accept", label: "Approve" }, + ]); + }), + ); + + it.effect("preserves MCP elicitation type when an app access request resolves", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-elicitation-resolved"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-08-24T00:00:00.000Z", + method: "item/requestApproval/decision", + requestKind: "mcp-elicitation", + requestId: ApprovalRequestId.make("req-safari"), + payload: { decision: "acceptAlways" }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "request.resolved") { + return; + } + NodeAssert.equal(firstEvent.value.payload.requestType, "mcp_elicitation_approval"); + NodeAssert.equal(firstEvent.value.payload.decision, "acceptAlways"); + }), + ); + it.effect("preserves file-read request type when mapping serverRequest/resolved", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index a0c8263f3e5e..5a99b910df0f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -58,6 +58,7 @@ import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, + describeMcpElicitation, makeCodexSessionRuntime, type CodexSessionRuntimeError, type CodexSessionRuntimeOptions, @@ -304,6 +305,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { return "file_read_approval"; case "item/fileChange/requestApproval": return "file_change_approval"; + case "mcpServer/elicitation/request": + return "mcp_elicitation_approval"; case "applyPatchApproval": return "apply_patch_approval"; case "execCommandApproval": @@ -327,6 +330,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical return "file_read_approval"; case "file-change": return "file_change_approval"; + case "mcp-elicitation": + return "mcp_elicitation_approval"; default: return "unknown"; } @@ -482,7 +487,9 @@ function mapItemLifecycle( lifecycle === "item.started" ? "inProgress" : lifecycle === "item.completed" - ? "completed" + ? "status" in item && (item.status === "failed" || item.status === "declined") + ? item.status + : "completed" : undefined; return { @@ -590,14 +597,9 @@ function mapCollabAgentEvent( }, ]; } - // interacted → the child is (again) actively driven. - return [ - { - ...base, - type: "task.updated", - payload: { taskId, status: "running", ...statusLinkage }, - }, - ]; + // Reading a child's result also emits "interacted" after its turn is idle. + // Only the child's turn or thread lifecycle can prove it resumed work. + return []; } case "collabAgent/turnStarted": return [ @@ -805,6 +807,11 @@ function mapToRuntimeEvents( ]; } + const elicitation = + event.method === "mcpServer/elicitation/request" + ? readPayload(EffectCodexSchema.McpServerElicitationRequestParams, event.payload) + : undefined; + const elicitationApproval = elicitation ? describeMcpElicitation(elicitation) : undefined; const detail = (() => { switch (event.method) { case "item/commandExecution/requestApproval": { @@ -821,6 +828,8 @@ function mapToRuntimeEvents( ); return payload?.reason ?? undefined; } + case "mcpServer/elicitation/request": + return elicitation?.message; case "applyPatchApproval": { const payload = readPayload( EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, @@ -854,6 +863,12 @@ function mapToRuntimeEvents( payload: { requestType: toRequestTypeFromMethod(event.method), ...(detail ? { detail } : {}), + ...(elicitationApproval + ? { + appName: elicitationApproval.appName, + options: elicitationApproval.options, + } + : {}), ...(event.payload !== undefined ? { args: event.payload } : {}), }, }, @@ -1978,6 +1993,16 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } return verified; }); + const uploadFeedback: CodexAdapterShape["uploadFeedback"] = (input) => + requireSession(input.threadId).pipe( + Effect.flatMap((session) => session.runtime.uploadFeedback(input.reason)), + Effect.map(({ threadId }) => ({ feedbackId: threadId })), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(input.threadId, "feedback/upload", cause), + ), + ); const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) => requireSession(threadId).pipe( @@ -2079,6 +2104,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( readThread, rollbackThread, rollbackThreadTo, + uploadFeedback, respondToRequest, respondToUserInput, stopSession, diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index a1b46e003520..5af06efb71dc 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -13,9 +13,11 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; +import { type ProviderApprovalDecision, type ProviderEvent, ThreadId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { assert, describe } from "vite-plus/test"; @@ -25,6 +27,14 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; const ROOT = wireFixture.rootThreadId; const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; const MEMORY = "memory-consolidation-thread"; +const decodeMcpElicitationResponse = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.Number, + result: Schema.Unknown, + }), + ), +); /** * The captured sequence, extended with the shapes the live capture didn't @@ -328,4 +338,116 @@ describe("CodexSessionRuntime collab integration", () => { yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + const elicitationCases = [ + { + decision: "accept", + response: { action: "accept", content: { approval: "once" } }, + }, + { + decision: "acceptForSession", + response: { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }, + }, + { + decision: "acceptAlways", + response: { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }, + }, + { decision: "decline", response: { action: "decline" } }, + { decision: "cancel", response: { action: "cancel" } }, + ] satisfies ReadonlyArray<{ + readonly decision: ProviderApprovalDecision; + readonly response: Record; + }>; + + for (const { decision, response } of elicitationCases) { + it.live(`returns the MCP elicitation ${decision} response to Codex`, () => + Effect.gen(function* () { + const scriptedRequest = { + id: 7001, + method: "mcpServer/elicitation/request", + params: { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: ROOT, + turnId: wireFixture.responses.turnStart.turn.id, + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + enum: ["once", "session", "always"], + }, + }, + required: ["approval"], + }, + }, + }; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + completeTurnOnServerResponse: true, + notifications: [], + serverRequests: [scriptedRequest], + }; + const responsesPath = `${scriptPath}.responses`; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(responsesPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(responsesPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-mcp-elicitation"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "auto", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const approvalRequested = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.runForEach((event) => + event.method === "mcpServer/elicitation/request" + ? Deferred.succeed(approvalRequested, event).pipe(Effect.asVoid) + : event.method === "turn/completed" + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "Open Safari" }); + const approval = yield* Deferred.await(approvalRequested); + assert.equal(approval.requestKind, "mcp-elicitation"); + assert.isDefined(approval.requestId); + if (approval.requestId === undefined) return; + + yield* runtime.respondToRequest(approval.requestId, decision); + yield* Deferred.await(turnCompleted); + + const recordedResponse = yield* decodeMcpElicitationResponse( + NodeFS.readFileSync(responsesPath, "utf8"), + ); + assert.equal(recordedResponse.id, scriptedRequest.id); + assert.deepEqual(recordedResponse.result, response); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + } }); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 26e77f82a79e..2aeebdb2ccd8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,25 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { - applyPreferredCodexDefaultModel, - isLegacyCodexModel, - mapCodexModelCapabilities, -} from "./CodexProvider.ts"; - -it("keeps only the GPT-5.6 Codex family out of legacy models", () => { - assert.deepStrictEqual( - ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.4"].map((model) => [ - model, - isLegacyCodexModel(model), - ]), - [ - ["gpt-5.6-luna", false], - ["gpt-5.6-terra", false], - ["gpt-5.6-sol", false], - ["gpt-5.4", true], - ], - ); -}); +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5c0f76dff4e3..52a8fdd25dc7 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,11 +62,6 @@ const REASONING_EFFORT_LABELS: Readonly> = { }; const DEFAULT_SERVICE_TIER_ID = "default"; -const CURRENT_CODEX_MODELS = new Set(["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]); - -export function isLegacyCodexModel(model: string): boolean { - return !CURRENT_CODEX_MODELS.has(model); -} function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; @@ -195,7 +190,6 @@ function parseCodexModelListResponse( name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), - ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } @@ -570,7 +564,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: installed ? `Codex app-server provider probe failed: ${error.message}.` - : "Codex CLI (`codex`) is not installed or not on PATH.", + : "Codex CLI (`codex`) was not found on PATH.", }, }); } @@ -601,6 +595,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu checkedAt, models: snapshot.models, skills: snapshot.skills, + slashCommands: [ + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ], probe: { installed: true, version: snapshot.version ?? null, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index c041f5a427ac..138c572321eb 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -18,10 +18,12 @@ import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, deleteCodexThread, + describeMcpElicitation, hasConfiguredMcpServer, isRecoverableThreadResumeError, makeMemoryConsolidationNotificationFilter, openCodexThread, + toMcpElicitationResponse, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -249,6 +251,208 @@ describe("buildTurnStartParams", () => { }); }); +describe("Codex MCP elicitation approvals", () => { + const request = { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + _meta: { + app_name: "Safari", + persist: ["session", "always"], + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + oneOf: [ + { const: "once", title: "Allow once" }, + { const: "session", title: "Allow for this session" }, + { const: "always", title: "Always allow Safari" }, + ], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + it("preserves the app name and advertised persistence choices", () => { + NodeAssert.deepStrictEqual(describeMcpElicitation(request), { + appName: "Safari", + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Allow for this session" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ], + }); + }); + + it("extracts the app name from a Computer Use request without metadata", () => { + const { _meta, ...requestWithoutMetadata } = request; + + NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari"); + }); + + it("returns the accepted form option to Codex", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), { + action: "accept", + content: { approval: "once" }, + }); + }); + + it("returns session-scoped approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptForSession"), { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }); + }); + + it("returns persistent approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("returns rejection without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), { + action: "decline", + }); + }); + + it("returns cancellation without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), { + action: "cancel", + }); + }); + + it("supports boolean permanent-approval fields", () => { + const booleanRequest = { + ...request, + _meta: { app_name: "Safari" }, + requestedSchema: { + type: "object", + properties: { + always: { type: "boolean", title: "Always allow Safari" }, + }, + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.ok( + describeMcpElicitation(booleanRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(booleanRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { always: true }, + }); + }); + + it("preserves valid nullable MCP form fields and persistence choices", () => { + const nullableRequest = { + ...request, + _meta: { + app_name: null, + appName: "Safari", + connector_name: null, + persist: null, + target: null, + tool_params: null, + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + title: null, + description: null, + default: null, + enum: ["once", "always"], + enumNames: null, + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.equal(describeMcpElicitation(nullableRequest).appName, "Safari"); + NodeAssert.ok( + describeMcpElicitation(nullableRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(nullableRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("declines required form fields that an approval prompt cannot collect", () => { + const inputRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + email: { type: "string", format: "email" }, + }, + required: ["email"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(inputRequest, "accept"), { + action: "decline", + }); + }); + + it("does not approve URL elicitations without opening their requested URL", () => { + const urlRequest = { + mode: "url", + message: "Finish signing in to continue.", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + elicitationId: "sign-in-1", + url: "https://example.com/authorize", + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), { + action: "decline", + }); + }); + + it("omits persistence choices that cannot satisfy required form fields", () => { + const onceOnlyRequest = { + ...request, + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + enum: ["once"], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(describeMcpElicitation(onceOnlyRequest).options, [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ]); + }); +}); + describe("buildCodexDeveloperInstructions", () => { it("appends runtime info after the mode instructions", () => { const instructions = buildCodexDeveloperInstructions("default", { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 791dcb825599..3dab8e199920 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -6,6 +6,7 @@ import { ProviderItemId, type ProviderInstanceId, type ProviderApprovalDecision, + type ProviderApprovalOption, type ProviderEvent, type ProviderInteractionMode, type ProviderRequestKind, @@ -73,6 +74,58 @@ const CodexUserInputAnswerObject = Schema.Struct({ }); const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject); +const NullableMcpElicitationString = Schema.NullOr(Schema.String); +const McpElicitationMetadata = Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + appName: Schema.optionalKey(NullableMcpElicitationString), + connector_name: Schema.optionalKey(NullableMcpElicitationString), + connectorName: Schema.optionalKey(NullableMcpElicitationString), + allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + persist: Schema.optionalKey( + Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + ), + target: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + tool_params: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), +}); +const McpElicitationFormField = Schema.Struct({ + type: Schema.optionalKey(NullableMcpElicitationString), + title: Schema.optionalKey(NullableMcpElicitationString), + description: Schema.optionalKey(NullableMcpElicitationString), + default: Schema.optionalKey(Schema.Unknown), + enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + oneOf: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + const: Schema.String, + title: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + ), +}); +const McpElicitationForm = Schema.Struct({ + properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)), + required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), +}); +const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); +const isMcpElicitationForm = Schema.is(McpElicitationForm); // TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated // `V2TurnStartParams` schema includes `collaborationMode` directly. @@ -143,6 +196,9 @@ export interface CodexSessionRuntimeShape { numTurns: number, ) => Effect.Effect; readonly deleteThread: Effect.Effect; + readonly uploadFeedback: ( + reason?: string, + ) => Effect.Effect; readonly respondToRequest: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -229,6 +285,172 @@ interface PendingUserInput { readonly answers: Deferred.Deferred; } +type McpElicitationPersistenceDecision = Extract< + ProviderApprovalDecision, + "acceptForSession" | "acceptAlways" +>; + +function mcpElicitationPersistenceDecision( + value: string, +): McpElicitationPersistenceDecision | null { + const normalized = value.toLowerCase(); + if (normalized.includes("session")) return "acceptForSession"; + if ( + normalized.includes("always") || + normalized.includes("permanent") || + normalized.includes("forever") || + normalized.includes("persistent") + ) { + return "acceptAlways"; + } + return null; +} + +function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { + if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { + return undefined; + } + return payload.requestedSchema; +} + +function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) { + if (field.oneOf) { + return field.oneOf.map((option) => ({ value: option.const, label: option.title })); + } + return (field.enum ?? []).map((value, index) => ({ + value, + label: field.enumNames?.[index], + })); +} + +function isMcpElicitationPersistenceField( + key: string, + field: typeof McpElicitationFormField.Type, +): boolean { + return ( + mcpElicitationPersistenceDecision(key) !== null || + key.toLowerCase() === "persist" || + mcpElicitationPersistenceDecision(field.title ?? "") !== null || + mcpElicitationPersistenceDecision(field.description ?? "") !== null + ); +} + +/** Returns the app and approval choices advertised by an MCP elicitation. */ +export function describeMcpElicitation( + payload: EffectCodexSchema.McpServerElicitationRequestParams, +): { readonly appName: string; readonly options: ReadonlyArray } { + const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined; + const appName = + metadata?.app_name ?? + metadata?.appName ?? + metadata?.app ?? + metadata?.target?.app ?? + metadata?.target?.name ?? + metadata?.tool_params?.app_name ?? + metadata?.tool_params?.app ?? + payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ?? + metadata?.connector_name ?? + metadata?.connectorName ?? + payload.serverName; + const persistenceOptions = new Map(); + const persist = metadata?.persist; + for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) { + const decision = mcpElicitationPersistenceDecision(value); + if (decision) persistenceOptions.set(decision, ""); + } + if (metadata?.allowPersistentApproval) { + persistenceOptions.set("acceptAlways", ""); + } + + const form = mcpElicitationFormFields(payload); + for (const [key, field] of Object.entries(form?.properties ?? {})) { + for (const option of mcpElicitationFieldOptions(field)) { + const decision = mcpElicitationPersistenceDecision(option.value); + if (decision) persistenceOptions.set(decision, option.label ?? ""); + } + if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + persistenceOptions.set("acceptAlways", field.title ?? ""); + } + } + + return { + appName, + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + ...(persistenceOptions.has("acceptForSession") && + toMcpElicitationResponse(payload, "acceptForSession").action === "accept" + ? [ + { + decision: "acceptForSession" as const, + label: persistenceOptions.get("acceptForSession") || "Always allow this session", + }, + ] + : []), + ...(persistenceOptions.has("acceptAlways") && + toMcpElicitationResponse(payload, "acceptAlways").action === "accept" + ? [ + { + decision: "acceptAlways" as const, + label: persistenceOptions.get("acceptAlways") || "Always allow", + }, + ] + : []), + { decision: "accept", label: "Approve" }, + ], + }; +} + +/** Converts a T3 approval decision into the MCP elicitation wire response. */ +export function toMcpElicitationResponse( + payload: EffectCodexSchema.McpServerElicitationRequestParams, + decision: ProviderApprovalDecision, +): EffectCodexSchema.McpServerElicitationRequestResponse { + if (decision === "decline" || decision === "cancel") { + return { action: decision }; + } + + if (payload.mode === "url") { + return { action: "decline" }; + } + + const persist = + decision === "acceptForSession" + ? "session" + : decision === "acceptAlways" + ? "always" + : undefined; + const form = mcpElicitationFormFields(payload); + const content: Record = {}; + + for (const [key, field] of Object.entries(form?.properties ?? {})) { + const options = mcpElicitationFieldOptions(field); + const chosenOption = options.find((option) => + persist + ? mcpElicitationPersistenceDecision(option.value) === decision + : /once|accept|approve|allow/i.test(option.value) && + mcpElicitationPersistenceDecision(option.value) === null, + ); + if (chosenOption) { + content[key] = chosenOption.value; + } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + content[key] = decision === "acceptAlways"; + } else if (field.default !== undefined && field.default !== null) { + content[key] = field.default; + } + } + + if (form?.required?.some((key) => !Object.hasOwn(content, key))) { + return { action: "decline" }; + } + + return { + action: "accept", + ...(persist ? { _meta: { persist } } : {}), + ...(form ? { content } : {}), + }; +} + type CodexServerNotification = { readonly [M in CodexRpc.ServerNotificationMethod]: { readonly method: M; @@ -1558,7 +1780,7 @@ export const makeCodexSessionRuntime = ( ), ); return { - decision: resolved, + decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse; }), ); @@ -1616,11 +1838,76 @@ export const makeCodexSessionRuntime = ( ), ); return { - decision: resolved, + decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse; }), ); + yield* client.handleServerRequest("mcpServer/elicitation/request", (payload) => + Effect.gen(function* () { + if (toMcpElicitationResponse(payload, "accept").action !== "accept") { + yield* Effect.logWarning("Declined an unsupported MCP elicitation.", { + serverName: payload.serverName, + mode: payload.mode, + }); + return { + action: "decline", + } satisfies EffectCodexSchema.McpServerElicitationRequestResponse; + } + + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("mcp-elicitation-request")); + const turnId = payload.turnId + ? TurnId.make(payload.turnId) + : (yield* Ref.get(sessionRef)).activeTurnId; + const jsonRpcId = payload.mode === "url" ? payload.elicitationId : requestId; + const decision = yield* Deferred.make(); + + yield* Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + jsonRpcId, + requestKind: "mcp-elicitation", + turnId, + itemId: undefined, + decision, + }); + return next; + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(jsonRpcId, { + requestId, + requestKind: "mcp-elicitation", + turnId, + itemId: undefined, + }); + return next; + }); + + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "mcpServer/elicitation/request", + requestId, + requestKind: "mcp-elicitation", + ...(turnId ? { turnId } : {}), + payload, + }); + + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + return toMcpElicitationResponse(payload, resolved); + }), + ); + yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => Effect.gen(function* () { const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); @@ -1934,6 +2221,16 @@ export const makeCodexSessionRuntime = ( deleteThread: Effect.flatMap(readProviderThreadId, (providerThreadId) => deleteCodexThread(client, providerThreadId), ), + uploadFeedback: (reason) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + return yield* client.request("feedback/upload", { + classification: "bug", + includeLogs: true, + ...(reason ? { reason } : {}), + threadId: providerThreadId, + }); + }), respondToRequest: (requestId, decision) => Effect.gen(function* () { const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 93f4b97995dc..7c07fe5ad4b8 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -239,19 +239,16 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { name: "openclaw-review", description: "Review OpenClaw workflow changes.", location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - content: "---\nname: openclaw-review\n---\n", }, { name: "openclaw-triage", description: "Triage OpenClaw routing issues.", location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - content: "---\nname: openclaw-triage\n---\n", }, { name: "missing-location", description: "This incomplete SDK row should be skipped.", location: "", - content: "---\nname: missing-location\n---\n", }, ], }; diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index c4145ecf1a0e..280601275a7e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -40,6 +40,7 @@ const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = { hasSession: vi.fn(), readThread: vi.fn(), rollbackThread: vi.fn(), + uploadFeedback: vi.fn(), stopAll: vi.fn(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index ae1cdd62acbb..23b588b1ad9f 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -41,6 +41,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; @@ -48,6 +49,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -107,6 +109,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => customModels: [], launchArgs: "", promptSuggestions: true, + autoCompactWindow: "", ...overrides, }); @@ -148,6 +151,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(ModelManifest.layerTest), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -313,6 +317,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(ModelManifest.layerTest), ); it.live("boots one instance of every shipped driver from a single config map", () => @@ -365,7 +370,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* makeProviderInstanceRegistry({ drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], configMap, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9a72ea83d3c0..663ee90368b8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -34,6 +34,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -381,6 +382,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te shortDescription: "Debug failing GitHub Actions checks", }, ]); + assert.deepStrictEqual(status.slashCommands, [ + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ]); }), ); @@ -494,10 +502,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI (`codex`) is not installed or not on PATH.", - ); + assert.strictEqual(status.message, "Codex CLI (`codex`) was not found on PATH."); }), ); @@ -1419,6 +1424,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -1462,7 +1468,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(codexPersonal?.installed, false); assert.strictEqual( codexPersonal?.message, - "Codex CLI (`codex`) is not installed or not on PATH.", + "Codex CLI (`codex`) was not found on PATH.", ); }).pipe(Effect.provide(runtimeServices)); }), @@ -1512,6 +1518,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { @@ -1634,6 +1641,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -1656,7 +1664,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); it.effect( - "keeps cursor disabled and skips probing when the provider setting is disabled", + "keeps Cursor disabled and skips provider probing when settings use their defaults", () => Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService( @@ -1666,9 +1674,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te codex: { enabled: false, }, - cursor: { - enabled: false, - }, grok: { enabled: false, }, @@ -1696,6 +1701,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( @@ -2150,6 +2156,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "review", description: "Review a pull request", @@ -2193,6 +2203,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "ui", description: "Explore and refine UI", @@ -2252,10 +2266,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent CLI (`claude`) is not installed or not on PATH.", - ); + assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH."); }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 3b37aecfedb8..603de97bf826 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -9,6 +9,8 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderTurnStartResult, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -209,6 +211,12 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { ): Effect.Effect<{ threadId: ThreadId; turns: readonly [] }, ProviderAdapterError> => Effect.succeed({ threadId, turns: [] }), ); + const uploadFeedback = vi.fn( + ( + input: ProviderUploadFeedbackInput, + ): Effect.Effect => + Effect.succeed({ feedbackId: `feedback-${input.threadId}` }), + ); const stopAll = vi.fn( (): Effect.Effect => @@ -234,6 +242,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { readThread, rollbackThread, rollbackThreadTo, + ...(provider === CODEX_DRIVER ? { uploadFeedback } : {}), stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -271,6 +280,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { readThread, rollbackThread, rollbackThreadTo, + uploadFeedback, stopAll, }; } @@ -612,6 +622,68 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance const routing = makeProviderServiceLayer(); +it.effect( + "ProviderServiceLive uploads feedback through the adapter that recovered the session", + () => + Effect.gen(function* () { + const original = makeFakeCodexAdapter(); + const replacement = makeFakeCodexAdapter(); + const baseRegistry = makeAdapterRegistryMock({ [CODEX_DRIVER]: original.adapter }); + let swapAfterFirstLookup = false; + let feedbackLookupCount = 0; + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { + ...baseRegistry, + getByInstance: (instanceId) => { + if (instanceId !== codexInstanceId) { + return baseRegistry.getByInstance(instanceId); + } + const useReplacement = swapAfterFirstLookup && feedbackLookupCount++ > 0; + return Effect.succeed(useReplacement ? replacement.adapter : original.adapter); + }, + }; + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-adapter-replacement"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* original.stopSession(threadId); + original.uploadFeedback.mockClear(); + replacement.uploadFeedback.mockClear(); + swapAfterFirstLookup = true; + + const result = yield* provider.uploadFeedback({ threadId }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.strictEqual(original.uploadFeedback.mock.calls.length, 0); + assert.deepStrictEqual(replacement.uploadFeedback.mock.calls, [[{ threadId }]]); + }).pipe(Effect.provide(providerLayer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); @@ -1114,6 +1186,93 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-route"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + routing.codex.uploadFeedback.mockClear(); + + const result = yield* provider.uploadFeedback({ + threadId, + reason: "The agent stopped early.", + }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [ + [{ threadId, reason: "The agent stopped early." }], + ]); + }), + ); + + it.effect("recovers a stopped Codex session before uploading feedback", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-recover"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/feedback-project", + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(threadId); + routing.codex.startSession.mockClear(); + routing.codex.uploadFeedback.mockClear(); + + const result = yield* provider.uploadFeedback({ threadId }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.strictEqual(routing.codex.startSession.mock.calls.length, 1); + assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [[{ threadId }]]); + }), + ); + + it.effect("rejects feedback for providers that do not support uploads", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-claude"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + + const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not support feedback uploads"); + routing.claude.startSession.mockClear(); + }), + ); + + it.effect("does not restart an unsupported provider before rejecting feedback", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-unsupported-stopped"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* routing.claude.stopSession(threadId); + routing.claude.startSession.mockClear(); + + const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not support feedback uploads"); + assert.strictEqual(routing.claude.startSession.mock.calls.length, 0); + }), + ); + it.effect("appends attachment file paths to the turn input text", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 77617c17a736..fd5ae1be5641 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -20,6 +20,7 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderUploadFeedbackInput, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, @@ -1339,6 +1340,46 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } }); + const uploadFeedback: ProviderServiceMethod<"uploadFeedback"> = Effect.fn("uploadFeedback")( + function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.uploadFeedback", + schema: ProviderUploadFeedbackInput, + payload: rawInput, + }); + let routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: false, + }); + if (routed.adapter.uploadFeedback === undefined) { + return yield* toValidationError( + "ProviderService.uploadFeedback", + `Provider '${routed.adapter.provider}' does not support feedback uploads.`, + ); + } + if (!routed.isActive) { + routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: true, + }); + } + const uploadFeedback = routed.adapter.uploadFeedback; + if (uploadFeedback === undefined) { + return yield* toValidationError( + "ProviderService.uploadFeedback", + `Provider '${routed.adapter.provider}' does not support feedback uploads.`, + ); + } + yield* Effect.annotateCurrentSpan({ + "provider.operation": "upload-feedback", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + }); + return yield* uploadFeedback(input); + }, + ); const runStopAll = Effect.fn("runStopAll")(function* () { const threadIds = yield* directory.listThreadIds(); @@ -1414,6 +1455,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( rollbackConversation, validateRollbackConversationTo, rollbackConversationTo, + uploadFeedback, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index ebdb65721848..37aff8311219 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -186,6 +186,7 @@ describe("ProviderSessionReaper", () => { }, rollbackConversation: () => unsupported(), rollbackConversationTo: () => unsupported(), + uploadFeedback: () => unsupported(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts new file mode 100644 index 000000000000..fdcfa9335424 --- /dev/null +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -0,0 +1,185 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + BUNDLED_MODEL_MANIFEST, + classifyModels, + isLegacyModel, + make, + type ModelManifestData, +} from "./ModelManifest.ts"; + +const CODEX = ProviderDriverKind.make("codex"); +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const CURSOR = ProviderDriverKind.make("cursor"); + +describe("isLegacyModel (bundled manifest)", () => { + it("keeps current Codex models out of legacy models", () => { + assert.deepStrictEqual( + [ + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest", + "gpt-5.4", + ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, model)]), + [ + ["gpt-5.6-luna", false], + ["gpt-5.6-terra", false], + ["gpt-5.6-sol", false], + ["gpt-daybreak-blue-latest", false], + ["gpt-daybreak-red-latest", false], + ["gpt-5.4", true], + ], + ); + }); + + it("keeps only the Claude 5 family out of legacy models", () => { + assert.deepStrictEqual( + ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ + model, + isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model), + ]), + [ + ["claude-fable-5", false], + ["claude-opus-5", false], + ["claude-sonnet-5", false], + ["claude-opus-4-8", true], + ], + ); + }); + + it("leaves driver kinds without a manifest entry unflagged", () => { + assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5")); + }); +}); + +const model = (overrides: Partial): ServerProviderModel => ({ + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities: null, + ...overrides, +}); + +describe("classifyModels", () => { + it("flags non-current models, clears stale flags, and skips custom models", () => { + const models = [ + model({ slug: "gpt-5.6-sol" }), + // Stale flag from a previous classification pass must be cleared. + model({ slug: "gpt-5.6-luna", isLegacy: true }), + model({ slug: "gpt-5.4" }), + // Custom models are user-defined and never reclassified. + model({ slug: "my-own-model", isCustom: true }), + ]; + assert.deepStrictEqual( + classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [ + entry.slug, + entry.isLegacy ?? false, + ]), + [ + ["gpt-5.6-sol", false], + ["gpt-5.6-luna", false], + ["gpt-5.4", true], + ["my-own-model", false], + ], + ); + }); +}); + +const REMOTE_MANIFEST: ModelManifestData = { + version: 1, + currentModels: { + codex: ["gpt-5.4"], + claudeAgent: ["claude-fable-5"], + }, +}; + +const httpClientLayer = (handler: () => Response) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler()))), + ); + +const serviceLayers = (input: { + readonly prefix: string; + readonly response: () => Response; + readonly settings?: Parameters[0]; +}) => + ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettings.layerTest(input.settings ?? {})), + Layer.provideMerge(httpClientLayer(input.response)), + ); + +describe("ModelManifest service", () => { + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => + Effect.gen(function* () { + const service = yield* make; + const refreshed = yield* service.refresh; + assert.deepStrictEqual(refreshed, REMOTE_MANIFEST); + assert.isTrue(isLegacyModel(refreshed, CODEX, "gpt-5.6-sol")); + assert.isFalse(isLegacyModel(refreshed, CODEX, "gpt-5.4")); + + // A fresh service instance sees the disk cache without another fetch: + // its HTTP layer is still stubbed, but `current` never fetches at all. + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-fetch-test", + response: () => Response.json(REMOTE_MANIFEST), + }), + ), + ), + ); + + it.live("keeps the bundled manifest when the remote payload is malformed", () => + Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-malformed-test", + response: () => Response.json({ version: 999, nonsense: true }), + }), + ), + ), + ); + + it.live("does not fetch when provider update checks are disabled", () => + Effect.gen(function* () { + let fetchCount = 0; + const service = yield* make.pipe( + Effect.provide( + httpClientLayer(() => { + fetchCount += 1; + return Response.json(REMOTE_MANIFEST); + }), + ), + ); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 0); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-optout-test", + response: () => Response.json(REMOTE_MANIFEST), + settings: { enableProviderUpdateChecks: false }, + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts new file mode 100644 index 000000000000..cb9494992287 --- /dev/null +++ b/apps/server/src/provider/ModelManifest.ts @@ -0,0 +1,221 @@ +/** + * ModelManifest — decides which provider models are current and which belong + * in the model picker's legacy section. + * + * The classification data (current slugs per driver kind) lives in + * `model-manifest.json` next to this file. The bundled copy ships with every + * release. At runtime the service refreshes it from the same file on `main` + * via raw.githubusercontent.com, so a new model can leave the legacy section + * with a commit to `main` instead of a release. Preference order is remote, + * then the on-disk copy of the last successful fetch, then the bundle. A + * failed fetch never fails a provider check. + * + * Drivers apply the manifest to snapshot drafts with `applyModelManifest` + * before publishing, so every path that produces models (pending, probe, + * error fallbacks) is classified the same way. + */ +import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import bundledManifestJson from "./model-manifest.json" with { type: "json" }; +import type { ServerProviderDraft } from "./providerSnapshot.ts"; + +const MODEL_MANIFEST_URL = + "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; + +/** How long a fetched manifest stays fresh before the next probe re-fetches. */ +const MANIFEST_TTL_MS = 60 * 60 * 1000; + +/** Minimum gap between fetch attempts after a failure, so an offline server + * does not pay a network timeout on every provider check. */ +const MANIFEST_RETRY_MS = 5 * 60 * 1000; + +const FETCH_TIMEOUT_MS = 10_000; + +/** + * `version` gates breaking schema changes: a build only accepts remote + * manifests whose version it understands, and keeps its bundled copy + * otherwise. `currentModels` is keyed by driver kind; kinds absent from the + * map have no legacy concept and their models are left unflagged. + */ +const ModelManifestSchema = Schema.Struct({ + version: Schema.Literal(1), + currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), +}); +export type ModelManifestData = typeof ModelManifestSchema.Type; + +const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); + +export const BUNDLED_MODEL_MANIFEST: ModelManifestData = + Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); + +/** On-disk shape of the last successfully fetched manifest. */ +const ManifestCacheFile = Schema.Struct({ + fetchedAtMs: Schema.Number, + manifest: ModelManifestSchema, +}); +const decodeManifestCache = Schema.decodeUnknownEffect( + Schema.fromJsonString( + ManifestCacheFile as unknown as Schema.Codec, + ), +); +const encodeManifestCache = Schema.encodeEffect( + Schema.fromJsonString( + ManifestCacheFile as unknown as Schema.Codec, + ), +); + +/** True when the manifest classifies `slug` as legacy for `driverKind`. */ +export function isLegacyModel( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, + slug: string, +): boolean { + const currentModels = manifest.currentModels[driverKind]; + if (!currentModels) return false; + return !currentModels.includes(slug); +} + +/** + * Reclassifies every built-in model on a snapshot draft against the manifest. + * Custom models are user-defined and never reclassified. + */ +export function applyModelManifest( + draft: ServerProviderDraft, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ServerProviderDraft { + return { ...draft, models: classifyModels(draft.models, manifest, driverKind) }; +} + +/** Model-level half of `applyModelManifest`, exported for focused tests. */ +export function classifyModels( + models: ReadonlyArray, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ReadonlyArray { + return models.map((model) => { + if (model.isCustom) return model; + if (isLegacyModel(manifest, driverKind, model.slug)) { + return model.isLegacy ? model : { ...model, isLegacy: true }; + } + if (!model.isLegacy) return model; + const { isLegacy: _isLegacy, ...rest } = model; + return rest; + }); +} + +export class ModelManifest extends Context.Service< + ModelManifest, + { + /** Manifest already in memory (disk cache or bundle); never fetches. + * Snapshot classification reads this, so it never waits on the network. */ + readonly current: Effect.Effect; + /** Manifest after a TTL-gated remote refresh; never fails. */ + readonly refresh: Effect.Effect; + /** Forks `refresh` into the service's own scope. Drivers call this from + * provider checks: the fetch is process-shared state, so it must survive + * the teardown of whichever instance happened to trigger it. */ + readonly refreshInBackground: Effect.Effect; + } +>()("t3/provider/ModelManifest") {} + +/** Constant service for tests and callers that only need the bundled data. */ +export const BundledOnlyModelManifest: ModelManifest["Service"] = { + current: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refreshInBackground: Effect.void, +}; + +export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + const serviceScope = yield* Effect.scope; + + const cachePath = path.join(config.stateDir, "model-manifest.json"); + let manifest = BUNDLED_MODEL_MANIFEST; + let fetchedAtMs: number | null = null; + let lastAttemptMs: number | null = null; + const refreshSemaphore = yield* Semaphore.make(1); + + // `Effect.cached` makes concurrent first readers await the same disk load + // rather than racing a "loaded" flag. Only `refreshed` takes the fetch + // semaphore; `current` must never wait behind an in-flight network refresh. + const ensureDiskCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const fromDisk = yield* fileSystem.readFileString(cachePath).pipe( + Effect.flatMap((raw) => decodeManifestCache(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk === null) return; + // The disk copy is the last-seen remote manifest, so it outranks the + // bundle even when stale: it is refreshed on the next successful fetch. + manifest = fromDisk.manifest; + fetchedAtMs = fromDisk.fetchedAtMs; + }), + ); + + const refresh = Effect.fn("ModelManifest.refresh")(function* () { + yield* ensureDiskCacheLoaded; + const now = yield* Clock.currentTimeMillis; + // A timestamp in the future means the wall clock moved backwards (the + // disk cache crosses restarts, so monotonic time cannot cover it). Treat + // it as expired: the refetch rewrites both timestamps and self-heals. + const isWithin = (sinceMs: number | null, windowMs: number) => + sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; + if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + + // The same switch that gates provider CLI update checks. It stops network + // fetches only: a manifest already cached on disk from an earlier fetch + // stays in effect, since the setting is about phoning home, not about + // discarding data the server already holds. + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings !== null && !settings.enableProviderUpdateChecks) return manifest; + + lastAttemptMs = now; + const fetched = yield* httpClient.get(MODEL_MANIFEST_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap((json) => decodeManifest(json)), + Effect.timeout(FETCH_TIMEOUT_MS), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) return manifest; + + manifest = fetched; + fetchedAtMs = now; + yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)), + Effect.catchCause(() => Effect.void), + ); + return manifest; + }); + + const guardedRefresh = refreshSemaphore.withPermits(1)(refresh()); + + return ModelManifest.of({ + current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), + refresh: guardedRefresh, + refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(ModelManifest, make); diff --git a/apps/server/src/provider/Services/CodexAdapter.ts b/apps/server/src/provider/Services/CodexAdapter.ts index 33fe0fa12be0..a0d9c0c28e9e 100644 --- a/apps/server/src/provider/Services/CodexAdapter.ts +++ b/apps/server/src/provider/Services/CodexAdapter.ts @@ -16,4 +16,8 @@ import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; * CodexAdapterShape — per-instance Codex adapter contract. Carries * a branded driver kind as the nominal discriminant. */ -export interface CodexAdapterShape extends ProviderAdapterShape {} +export interface CodexAdapterShape extends ProviderAdapterShape { + readonly uploadFeedback: NonNullable< + ProviderAdapterShape["uploadFeedback"] + >; +} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index ee712aed6e71..49baf9f4f1ab 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -16,6 +16,8 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, ThreadId, ProviderTurnStartResult, TurnId, @@ -144,6 +146,13 @@ export interface ProviderAdapterShape { targetTurnId?: TurnId, ) => Effect.Effect; + /** + * Upload a thread to the provider when the adapter supports feedback. + */ + readonly uploadFeedback?: ( + input: ProviderUploadFeedbackInput, + ) => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 4b53021bdb76..dc5dd3ce2908 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -21,6 +21,8 @@ import type { ProviderSession, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, ThreadId, ProviderTurnStartResult, TurnId, @@ -132,6 +134,13 @@ export interface ProviderServiceShape { readonly targetTurnId?: TurnId; }) => Effect.Effect; + /** + * Upload a thread and return the provider's shareable feedback identifier. + */ + readonly uploadFeedback: ( + input: ProviderUploadFeedbackInput, + ) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9cb..dc0dd6e0436f 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import type * as EffectAcpSchema from "effect-acp/schema"; import { + decideToolCallUpdateEmission, extractModelConfigId, mergeToolCallState, parsePermissionRequest, @@ -10,6 +11,7 @@ import { parseSessionUpdateEvent, sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, + type AcpToolCallState, } from "./AcpRuntimeModel.ts"; describe("AcpRuntimeModel", () => { @@ -374,4 +376,281 @@ describe("AcpRuntimeModel", () => { }, }); }); + + it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { + // Mirrors Grok's ACP CLI resending the ENTIRE accumulated terminal output on every + // tool_call_update notification instead of a delta (see upstream #6556). + const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( + "\n", + ); + expect(hugeText.length).toBeGreaterThan(60_000); + + const result = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + // Real ACP `tool_call_update` deltas typically omit `title` (already established by + // the initial `tool_call`); that is also the shape that surfaces raw content as detail. + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: hugeText } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(result.events).toHaveLength(1); + const event = result.events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBeDefined(); + const detail = event.toolCall.detail!; + // 8000 chars of tail plus the truncation marker, regardless of input size. + expect(detail.length).toBe(8_028); + expect(detail.startsWith("[Earlier output truncated]")).toBe(true); + expect(detail.endsWith(hugeText.slice(-100))).toBe(true); + + // The raw payload threaded through for logging/persistence must not smuggle the full + // cumulative buffer back in either. + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); + expect(JSON.stringify(event).length).toBeLessThan(hugeText.length); + }); + + it("coalesces 1000 rapid cumulative tool_call_update notifications for a redrawing progress bar", () => { + let previous: AcpToolCallState | undefined; + let lastEmittedDetailLength: number | undefined; + let skippedSinceEmit = 0; + let emittedCount = 0; + let emittedBytes = 0; + let notificationBytes = 0; + let largestEmittedEventBytes = 0; + let finalDetail: string | undefined; + let cumulativeBuffer = ""; + + for (let i = 0; i < 1_000; i += 1) { + // Grok resends the FULL accumulated buffer, not a delta, on every redraw. + cumulativeBuffer += `frame ${i}: ${"#".repeat(50)}\n`; + const isLast = i === 999; + + const notification = { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: isLast ? "completed" : "in_progress", + content: [{ type: "content", content: { type: "text", text: cumulativeBuffer } }], + }, + } satisfies EffectAcpSchema.SessionNotification; + notificationBytes += JSON.stringify(notification).length; + + const { events } = parseSessionUpdateEvent(notification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + continue; + } + + const merged = mergeToolCallState(previous, event.toolCall); + const decision = decideToolCallUpdateEmission({ + previous, + next: merged, + lastEmittedDetailLength, + skippedSinceEmit, + }); + previous = merged; + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + emittedCount += 1; + const eventBytes = JSON.stringify({ + toolCall: merged, + rawPayload: event.rawPayload, + }).length; + emittedBytes += eventBytes; + largestEmittedEventBytes = Math.max(largestEmittedEventBytes, eventBytes); + lastEmittedDetailLength = merged.detail?.length; + finalDetail = merged.detail; + } + } + + // The flood as the CLI sends it: 1000 cumulative redraws, ~31.6 MB of JSON. + expect(notificationBytes).toBeGreaterThan(31_000_000); + + // 1000 cumulative redraws collapse into a fixed, small number of runtime events... + expect(emittedCount).toBe(114); + // ...each individually bounded, no matter how long the tool call runs... + expect(largestEmittedEventBytes).toBeLessThan(25_000); + // ...so the whole flooding tool call costs ~2.5 MB of runtime events instead of ~31.6 MB. + expect(emittedBytes).toBeLessThan(2_600_000); + // ...while the FINAL state (forced by the completed status) still reflects the real, + // latest output rather than a stale coalesced value. + expect(finalDetail).toBeDefined(); + expect(finalDetail?.endsWith(`frame 999: ${"#".repeat(50)}`)).toBe(true); + }); + + it("keeps non-text tool call content entries in order when bounding oversized text", () => { + const hugePrefix = "x".repeat(25_000); + const hugeTail = "y".repeat(25_000); + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "edit", + status: "in_progress", + content: [ + { type: "content", content: { type: "text", text: hugePrefix } }, + { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, + { type: "content", content: { type: "text", text: hugeTail } }, + { type: "diff", path: "/repo/other.ts", oldText: "old", newText: "new" }, + { type: "content", content: { type: "text", text: " " } }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + const content = event.toolCall.data.content as ReadonlyArray; + expect(content).toHaveLength(3); + expect(content[0]).toEqual({ + type: "diff", + path: "/repo/file.ts", + oldText: "before", + newText: "after", + }); + const lastEntry = content[1]; + if (lastEntry?.type !== "content" || lastEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(lastEntry.content.text.length).toBeLessThan(8_100); + expect(lastEntry.content.text.endsWith(hugeTail.slice(-100))).toBe(true); + expect(content[2]).toEqual({ + type: "diff", + path: "/repo/other.ts", + oldText: "old", + newText: "new", + }); + }); + + describe("decideToolCallUpdateEmission", () => { + const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) => + ({ + toolCallId: "tool-1", + title: "Grok Tool", + ...(status ? { status } : {}), + ...(detail ? { detail } : {}), + data: {}, + }) satisfies AcpToolCallState; + + it("always emits terminal (completed/failed) status updates regardless of growth", () => { + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "inProgress"), + next: toolCall("same", "completed"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "inProgress"), + next: toolCall("same", "failed"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 3, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("skips updates whose bounded detail did not change", () => { + expect( + decideToolCallUpdateEmission({ + previous: toolCall("frame 1", "inProgress"), + next: toolCall("frame 1", "inProgress"), + lastEmittedDetailLength: 7, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: false, skippedSinceEmit: 0 }); + }); + + it("emits immediately when the title changes, even with no growth", () => { + const decision = decideToolCallUpdateEmission({ + previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, + next: { toolCallId: "tool-1", title: "Ran command", detail: "x", data: {} }, + lastEmittedDetailLength: 1, + skippedSinceEmit: 0, + }); + expect(decision).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("coalesces small deltas but forces an emission after the coalesce limit", () => { + let lastEmittedDetailLength: number | undefined = 0; + let skippedSinceEmit = 0; + const emissions: Array = []; + let previous: AcpToolCallState | undefined; + + for (let i = 1; i <= 12; i += 1) { + // Grows by 1 char per update — well under the 256-char growth threshold, so this + // exercises the coalesce-count fallback rather than the growth-based trigger. + const next = toolCall("x".repeat(i), "inProgress"); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + emissions.push(decision.emit); + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + lastEmittedDetailLength = next.detail?.length; + } + previous = next; + } + + // First update always emits (no previous state yet); after that, small per-update + // growth should be coalesced until the coalesce limit forces a periodic emission. + const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); + expect(emittedIndices).toEqual([1, 11]); + }); + + it("retains the latest replacement snapshot when equal-length updates are coalesced", () => { + let previous: AcpToolCallState = toolCall("frame-a", "inProgress"); + const lastEmittedDetailLength = previous.detail?.length; + let skippedSinceEmit = 0; + + for (const detail of ["frame-b", "frame-c"]) { + const next = mergeToolCallState(previous, toolCall(detail, "inProgress")); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + expect(decision.emit).toBe(false); + skippedSinceEmit = decision.skippedSinceEmit; + previous = next; + } + + const completed = mergeToolCallState(previous, toolCall(undefined, "completed")); + expect(completed.detail).toBe("frame-c"); + expect( + decideToolCallUpdateEmission({ + previous, + next: completed, + lastEmittedDetailLength, + skippedSinceEmit, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + }); }); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e9..2e561c4cc328 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -264,25 +264,97 @@ function extractToolCallCommand(rawInput: unknown, title: string | undefined): s return extractCommandFromTitle(title); } +// Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call +// output on every `tool_call_update` notification instead of a delta, so a redrawing +// terminal progress bar can balloon a single tool call to hundreds of KB per update at +// several updates per second. Cap what we retain/emit to a bounded tail so one busy tool +// call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update` +// deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal +// from another tool here, and the end is the useful part of any live-growing output. +const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; +const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; + +function boundToolCallOutputText(text: string): string { + if (text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return text; + } + const tail = text.slice(text.length - TOOL_CALL_CONTENT_MAX_CHARS); + return `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail}`; +} + +const RAW_OUTPUT_TEXT_FIELDS = ["content", "stdout", "stderr", "output"] as const; + +// `rawOutput` is provider-defined and, for terminal-shaped tools, mirrors the same +// cumulative text-growth problem as `content` (see the comment above). Bound its known +// text-bearing fields the same way so a chatty provider cannot smuggle unbounded output +// through this field instead. +function boundToolCallRawOutput(rawOutput: unknown): unknown { + if (!isRecord(rawOutput)) { + return rawOutput; + } + let changed = false; + const bounded: Record = { ...rawOutput }; + for (const field of RAW_OUTPUT_TEXT_FIELDS) { + const value = rawOutput[field]; + if (typeof value === "string" && value.length > TOOL_CALL_CONTENT_MAX_CHARS) { + bounded[field] = boundToolCallOutputText(value); + changed = true; + } + } + return changed ? bounded : rawOutput; +} + +interface ExtractedToolCallContent { + readonly text: string | undefined; + readonly content: ReadonlyArray | undefined; +} + +function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | undefined { + if (entry.type !== "content" || entry.content.type !== "text") { + return undefined; + } + return entry.content.text; +} + function extractTextContentFromToolCallContent( content: ReadonlyArray | null | undefined, -): string | undefined { - if (!content) return undefined; +): ExtractedToolCallContent { + if (!content) { + return { text: undefined, content: undefined }; + } const chunks: Array = []; for (const entry of content) { - if (entry.type !== "content") { - continue; - } - const nestedContent = entry.content; - if (nestedContent.type !== "text") { - continue; - } - const text = nestedContent.text.trim(); - if (text.length > 0) { + const text = toolCallContentText(entry)?.trim(); + if (text) { chunks.push(text); } } - return chunks.length > 0 ? chunks.join("\n") : undefined; + if (chunks.length === 0) { + return { text: undefined, content }; + } + const joined = chunks.join("\n"); + if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return { text: joined, content }; + } + const bounded = boundToolCallOutputText(joined); + // Collapse the text entries into a single bounded one at the final contributing text entry, + // and leave every other entry kind (diffs, images, resource links) in its original relative + // order. The retained tail came from that text entry, so placing it there also preserves its + // ordering relative to interleaved non-text content and ignores later blank text entries. + const lastContributingTextIndex = content.reduce( + (lastIndex, entry, index) => (toolCallContentText(entry)?.trim() ? index : lastIndex), + -1, + ); + const boundedContent = content.flatMap((entry, index) => { + if (toolCallContentText(entry) === undefined) { + return [entry]; + } + if (index !== lastContributingTextIndex) { + return []; + } + return [{ type: "content", content: { type: "text", text: bounded } } as const]; + }); + return { text: bounded, content: boundedContent }; } function normalizeToolKind(kind: unknown): string | undefined { @@ -326,7 +398,8 @@ function makeToolCallState( } const title = input.title?.trim() || undefined; const command = extractToolCallCommand(input.rawInput, title); - const textContent = extractTextContentFromToolCallContent(input.content); + const extractedContent = extractTextContentFromToolCallContent(input.content); + const textContent = extractedContent.text; const normalizedTitle = title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" ? title @@ -343,10 +416,10 @@ function makeToolCallState( data.rawInput = input.rawInput; } if (input.rawOutput !== undefined) { - data.rawOutput = input.rawOutput; + data.rawOutput = boundToolCallRawOutput(input.rawOutput); } if (input.content !== undefined) { - data.content = input.content; + data.content = extractedContent.content ?? input.content; } if (input.locations !== undefined) { data.locations = input.locations; @@ -424,6 +497,53 @@ export function mergeToolCallState( }; } +// Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal +// can still shift its bounded tail window on nearly every notification, which would emit +// a runtime event per redraw. Coalesce those: only emit early when the tool call's detail +// has grown meaningfully since the last emission, otherwise batch up to a small number of +// skipped updates before emitting anyway, so the UI still gets periodic progress and the +// final (completed/failed) state is always emitted immediately. +const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; +const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; + +export interface AcpToolCallEmitDecisionInput { + readonly previous: AcpToolCallState | undefined; + readonly next: AcpToolCallState; + readonly lastEmittedDetailLength: number | undefined; + readonly skippedSinceEmit: number; +} + +export interface AcpToolCallEmitDecision { + readonly emit: boolean; + readonly skippedSinceEmit: number; +} + +export function decideToolCallUpdateEmission( + input: AcpToolCallEmitDecisionInput, +): AcpToolCallEmitDecision { + const { previous, next, lastEmittedDetailLength, skippedSinceEmit } = input; + if (next.status === "completed" || next.status === "failed") { + return { emit: true, skippedSinceEmit: 0 }; + } + if (!next.detail) { + return { emit: false, skippedSinceEmit }; + } + if (previous === undefined || previous.title !== next.title) { + return { emit: true, skippedSinceEmit: 0 }; + } + if (previous.detail === next.detail) { + return { emit: false, skippedSinceEmit }; + } + const grewMeaningfully = + lastEmittedDetailLength === undefined || + Math.abs(next.detail.length - lastEmittedDetailLength) >= + TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; + if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) { + return { emit: true, skippedSinceEmit: 0 }; + } + return { emit: false, skippedSinceEmit: skippedSinceEmit + 1 }; +} + export function parsePermissionRequest( params: EffectAcpSchema.RequestPermissionRequest, ): AcpPermissionRequest { @@ -505,6 +625,33 @@ export function syntheticLoadSessionResponseFromInitialize( }; } +// The parsed AcpToolCallState already carries bounded content (see makeToolCallState / +// extractTextContentFromToolCallContent above), but the raw JSON-RPC notification is also +// threaded through as `rawPayload` for logging/debugging and ends up persisted on the +// runtime event. Substitute the same bounded `content`/`rawOutput` there so an oversized +// cumulative update cannot smuggle the unbounded buffer back in through the raw payload. +function boundToolCallRawPayload( + params: EffectAcpSchema.SessionNotification, + update: AcpToolCallUpdate, + toolCall: AcpToolCallState, +): unknown { + const boundedContent = toolCall.data.content; + const boundedRawOutput = toolCall.data.rawOutput; + const contentBounded = update.content !== undefined && boundedContent !== update.content; + const rawOutputBounded = update.rawOutput !== undefined && boundedRawOutput !== update.rawOutput; + if (!contentBounded && !rawOutputBounded) { + return params; + } + return { + ...params, + update: { + ...update, + ...(contentBounded ? { content: boundedContent } : {}), + ...(rawOutputBounded ? { rawOutput: boundedRawOutput } : {}), + }, + }; +} + export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { readonly modeId?: string; readonly events: ReadonlyArray; @@ -548,7 +695,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ToolCallUpdated", toolCall, - rawPayload: params, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), }); } break; @@ -559,7 +706,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ToolCallUpdated", toolCall, - rawPayload: params, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), }); } break; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..0dcf72895fd8 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -23,6 +23,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectSessionConfigOptionValues, + decideToolCallUpdateEmission, extractModelConfigId, findSessionConfigOption, mergeToolCallState, @@ -36,6 +37,12 @@ import { type AcpToolCallState, } from "./AcpRuntimeModel.ts"; +interface AcpToolCallTrackedState { + readonly state: AcpToolCallState; + readonly lastEmittedDetailLength: number | undefined; + readonly skippedSinceEmit: number; +} + function formatConfigOptionValue(value: string | boolean): string { return JSON.stringify(value); } @@ -279,7 +286,7 @@ export const make = ( const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); - const toolCallsRef = yield* Ref.make(new Map()); + const toolCallsRef = yield* Ref.make(new Map()); const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -851,7 +858,7 @@ const handleSessionUpdate = ({ }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; - readonly toolCallsRef: Ref.Ref>; + readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; @@ -869,18 +876,31 @@ const handleSessionUpdate = ({ queue, assistantSegmentRef, }); - const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => { - const previous = current.get(event.toolCall.toolCallId); + const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { + const tracked = current.get(event.toolCall.toolCallId); + const previous = tracked?.state; const nextToolCall = mergeToolCallState(previous, event.toolCall); + const decision = decideToolCallUpdateEmission({ + previous, + next: nextToolCall, + lastEmittedDetailLength: tracked?.lastEmittedDetailLength, + skippedSinceEmit: tracked?.skippedSinceEmit ?? 0, + }); const next = new Map(current); if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { next.delete(nextToolCall.toolCallId); } else { - next.set(nextToolCall.toolCallId, nextToolCall); + next.set(nextToolCall.toolCallId, { + state: nextToolCall, + lastEmittedDetailLength: decision.emit + ? nextToolCall.detail?.length + : tracked?.lastEmittedDetailLength, + skippedSinceEmit: decision.skippedSinceEmit, + }); } - return [{ previous, merged: nextToolCall }, next] as const; + return [{ merged: nextToolCall, decision }, next] as const; }); - if (!shouldEmitToolCallUpdate(previous, merged)) { + if (!decision.emit) { continue; } yield* Queue.offer(queue, { @@ -926,19 +946,6 @@ function updateModeState(modeState: AcpSessionModeState, nextModeId: string): Ac : modeState; } -function shouldEmitToolCallUpdate( - previous: AcpToolCallState | undefined, - next: AcpToolCallState, -): boolean { - if (next.status === "completed" || next.status === "failed") { - return true; - } - if (!next.detail) { - return false; - } - return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; -} - const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json new file mode 100644 index 000000000000..7022ce226170 --- /dev/null +++ b/apps/server/src/provider/model-manifest.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "currentModels": { + "codex": [ + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest" + ], + "claudeAgent": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] + } +} diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 8d5ba353389d..f02bf997c5d1 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -258,7 +258,7 @@ describe("parseAgentListCliOutput", () => { }); describe("parseSkillsCliOutput", () => { - it("parses skill metadata from the CLI JSON output", () => { + it("parses only skill metadata from the CLI JSON output", () => { const result = parseSkillsCliOutput( JSON.stringify([ { @@ -275,7 +275,6 @@ describe("parseSkillsCliOutput", () => { name: "review-pr", description: "Review a pull request.", location: "/tmp/review-pr/SKILL.md", - content: "---\nname: review-pr\n---\n", }, ]); }); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 8b22a52a2060..7db63745eafe 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -4,13 +4,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { OpencodeClient } from "@opencode-ai/sdk/v2"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); -it.layer(testLayer)("loadOpenCodeInventory", (it) => { +it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { it.effect("keeps provider inventory when skill discovery fails", () => Effect.gen(function* () { const runtime = yield* OpenCodeRuntime; @@ -38,4 +45,121 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => { NodeAssert.deepEqual(inventory.skills, []); }), ); + + it.effect("keeps only SDK skill metadata in inventory", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const client = { + provider: { + list: () => + Promise.resolve({ + data: { + connected: ["openai"], + all: [], + default: {}, + }, + }), + }, + app: { + agents: () => Promise.resolve({ data: [] }), + skills: () => + Promise.resolve({ + data: [ + { + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + content: "unused skill content", + }, + ], + }), + }, + } as unknown as OpencodeClient; + + const inventory = yield* runtime.loadOpenCodeInventory(client); + + NodeAssert.deepEqual(inventory.skills, [ + { + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + }, + ]); + }), + ); + + it.effect("drops oversized CLI skill output without losing the model inventory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostEnvironment = yield* HostProcessEnvironment; + const executablePath = yield* HostProcessExecutablePath; + const hostPlatform = yield* HostProcessPlatform; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-inventory-" }); + const isWindows = hostPlatform === "win32"; + const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); + const scriptPath = path.join(tempDir, "opencode.mjs"); + const oversizedContentBytes = 8 * 1024 * 1024 + 1; + + yield* fs.writeFileString( + scriptPath, + [ + 'if (process.argv[2] === "models") {', + ' process.stdout.write(`openai/gpt-test\\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\\n`);', + '} else if (process.argv[2] === "debug") {', + ` const content = "x".repeat(${oversizedContentBytes});`, + ' process.stdout.write(`[{"name":"oversized","content":"${content}"}]`);', + "}", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + binaryPath, + [ + ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), + isWindows + ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' + : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', + "", + ].join("\n"), + ); + if (!isWindows) { + yield* fs.chmod(binaryPath, 0o755); + } + + const runtime = yield* OpenCodeRuntime; + const inventory = yield* runtime.loadInventoryFromCli({ + binaryPath, + cwd: tempDir, + environment: { + ...hostEnvironment, + T3_TEST_NODE_BINARY: executablePath, + T3_TEST_OPENCODE_SCRIPT: scriptPath, + }, + }); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.equal(inventory.skills.length, 0); + }), + ); + + it.effect("caps and drains command stdout and stderr when requested", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const executablePath = yield* HostProcessExecutablePath; + const outputBytes = 2 * 1024 * 1024; + const result = yield* runtime.runOpenCodeCommand({ + binaryPath: executablePath, + args: [ + "-e", + `process.stdout.write("o".repeat(${outputBytes})); process.stderr.write("e".repeat(${outputBytes}));`, + ], + maxOutputBytes: 64, + }); + + NodeAssert.equal(result.stdout, "o".repeat(64)); + NodeAssert.equal(result.stderr, "e".repeat(64)); + NodeAssert.equal(result.code, 0); + }), + ); }); diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/apps/server/src/provider/opencodeRuntime.permissions.test.ts new file mode 100644 index 000000000000..ad95e38d1495 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.permissions.test.ts @@ -0,0 +1,44 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { buildOpenCodePermissionRules } from "./opencodeRuntime.ts"; + +function actionFor( + runtimeMode: Parameters[0], + permission: string, +) { + return buildOpenCodePermissionRules(runtimeMode).find((rule) => rule.permission === permission) + ?.action; +} + +describe("buildOpenCodePermissionRules", () => { + it("pre-approves edits once the user has chosen to auto-accept them", () => { + NodeAssert.equal(actionFor("auto-accept-edits", "edit"), "allow"); + }); + + it("still asks before editing when approval is required", () => { + NodeAssert.equal(actionFor("approval-required", "edit"), "ask"); + }); + + // Documented in docs/user/permission-modes.md: providers without an AI + // reviewer, OpenCode among them, fall back to Supervised for "auto". + it("leaves auto asking, as the docs say it does without a reviewer", () => { + NodeAssert.equal(actionFor("auto", "edit"), "ask"); + }); + + it("keeps asking for everything else in the auto modes", () => { + for (const runtimeMode of ["auto-accept-edits", "auto"] as const) { + NodeAssert.equal(actionFor(runtimeMode, "bash"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "webfetch"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "external_directory"), "ask"); + NodeAssert.equal(actionFor(runtimeMode, "*"), "ask"); + } + }); + + it("allows everything only under full access", () => { + NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [ + { permission: "*", pattern: "*", action: "allow" }, + ]); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..80329a6794d5 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -51,6 +51,7 @@ export function resolveOpenCodeConfigContent( const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; +const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; export interface OpenCodeServerProcess { readonly url: string; readonly exitCode: Effect.Effect; @@ -124,14 +125,12 @@ export interface OpenCodeSkill { readonly name?: string | null; readonly description?: string | null; readonly location?: string | null; - readonly content?: string | null; } const OpenCodeSkillSchema = Schema.Struct({ name: Schema.optionalKey(Schema.NullOr(Schema.String)), description: Schema.optionalKey(Schema.NullOr(Schema.String)), location: Schema.optionalKey(Schema.NullOr(Schema.String)), - content: Schema.optionalKey(Schema.NullOr(Schema.String)), }); const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), @@ -169,6 +168,7 @@ export interface OpenCodeRuntimeShape { readonly args: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; readonly cwd?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly createOpenCodeSdkClient: (input: { readonly baseUrl: string; @@ -373,10 +373,16 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi return [{ permission: "*", pattern: "*", action: "allow" }]; } + // "Auto-accept edits" is documented as "auto-approve edits, ask before other + // actions", so prompting for every edit ignores the mode the user picked. + // "auto" is left asking on purpose: the docs say providers without an AI + // reviewer, OpenCode among them, fall back to Supervised for that mode. + const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; + return [ { permission: "*", pattern: "*", action: "ask" }, { permission: "bash", pattern: "*", action: "ask" }, - { permission: "edit", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: editAction }, { permission: "webfetch", pattern: "*", action: "ask" }, { permission: "websearch", pattern: "*", action: "ask" }, { permission: "codesearch", pattern: "*", action: "ask" }, @@ -447,8 +453,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); + const collectOptions = + input.maxOutputBytes === undefined ? undefined : { maxBytes: input.maxOutputBytes }; const [stdout, stderr, code] = yield* Effect.all( - [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], + [ + collectStreamAsString(child.stdout, collectOptions), + collectStreamAsString(child.stderr, collectOptions), + child.exitCode, + ], { concurrency: "unbounded" }, ); const exitCode = Number(code); @@ -702,7 +714,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const loadSkills = (client: OpencodeClient) => runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( - Effect.map((result) => (result.data ?? []) as ReadonlyArray), + Effect.map((result) => + (result.data ?? []).map((skill) => ({ + name: skill.name, + ...(skill.description === undefined ? {} : { description: skill.description }), + location: skill.location, + })), + ), Effect.orElseSucceed((): ReadonlyArray => []), ); @@ -732,6 +750,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { runOpenCodeCommand({ binaryPath: input.binaryPath, args: ["debug", "skill"], + maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, ...commandContext, }).pipe(Effect.exit); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 03b4cf4a3b6e..adbe110d9408 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -255,5 +255,6 @@ export function buildServerProvider(input: { export const collectStreamAsString = ( stream: Stream.Stream, + options?: { readonly maxBytes?: number | undefined }, ): Effect.Effect => - collectUint8StreamText({ stream }).pipe(Effect.map((collected) => collected.text)); + collectUint8StreamText({ stream, ...options }).pipe(Effect.map((collected) => collected.text)); diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index f06e984c9aa5..d3bdee367125 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -17,6 +17,7 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); let turnStartCount = 0; +let activeTurn; const rl = NodeReadline.createInterface({ input: process.stdin }); rl.on("line", (line) => { @@ -27,6 +28,23 @@ rl.on("line", (line) => { return; } const { id, method } = message; + if (method === undefined && script.serverRequests?.some((request) => request.id === id)) { + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`, + `${JSON.stringify({ id, result: message.result, error: message.error })}\n`, + ); + if (script.completeTurnOnServerResponse && activeTurn) { + write({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + threadId: script.rootThreadId, + turn: { ...activeTurn, status: "completed" }, + }, + }); + } + return; + } if (method === "initialize") { write({ id, @@ -48,6 +66,7 @@ rl.on("line", (line) => { const turn = turnId ? { ...fixture.responses.turnStart.turn, id: turnId } : fixture.responses.turnStart.turn; + activeTurn = turn; turnStartCount += 1; write({ id, result: { ...fixture.responses.turnStart, turn } }); const rootThreadId = script.rootThreadId; @@ -61,6 +80,9 @@ rl.on("line", (line) => { for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } + for (const request of script.serverRequests ?? []) { + write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params }); + } if (script.holdTurnOpen !== true) { write({ jsonrpc: "2.0", diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 84bd57dfa27b..d142e7368f6d 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import type { OrchestrationProjectShell, ProjectId, @@ -2292,6 +2293,42 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o }), ); +it.effect("returns the refreshed listing on the first read after its cache expires", () => + Effect.gen(function* () { + let hostCalls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ + items: [changeRequest(hostCalls, "2026-07-02T00:00:00Z")], + truncated: false, + continues: false, + }); + }, + }), + ], + }); + + const first = yield* service.list({ state: "open" }); + assert.deepStrictEqual( + first.entries.map((entry) => entry.number), + [1], + ); + + yield* TestClock.adjust("31 seconds"); + const refreshed = yield* service.list({ state: "open" }); + + assert.strictEqual(hostCalls, 2); + assert.deepStrictEqual( + refreshed.entries.map((entry) => entry.number), + [2], + ); + }), +); + it.effect("a listing narrowed to some projects is its own cache entry", () => Effect.gen(function* () { const asked: ReadonlyArray[] = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index fc76a6501931..3a0d1aac699d 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -102,16 +102,7 @@ const DIFF_CACHE_TTL = Duration.seconds(60); const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); /** Sized like the client's own stale time; a row's counts move only when somebody pushes. */ const LIST_STATS_CACHE_TTL = Duration.seconds(60); -/** - * How long a cache's last success may still be served while a fresh read runs behind it. - * Bounded by how the page actually revalidates: clients re-read on mount and once a minute - * while open, and every one of those reads repopulates the cache in the background — so in - * steady use a "stale" answer is at most a refresh cycle old, and the window only stretches - * that far when nobody has looked at the page for minutes. An explicit refresh or a mutation - * bumps the epochs and skips held answers entirely. - */ -const LIST_STALE_WINDOW = Duration.minutes(10); -const DETAIL_STALE_WINDOW = Duration.minutes(5); +/** A diff can stay interactive while its next cached value is fetched off the critical path. */ const DIFF_STALE_WINDOW = Duration.minutes(10); /** How long one host's signed-in login is believed without asking its CLI again. */ const VIEWER_CACHE_TTL = Duration.minutes(10); @@ -1826,30 +1817,22 @@ export const make = Effect.gen(function* () { const runFork = Effect.runForkWith(context); /** - * Stale answers served while a fresh one is fetched behind them. Every read here leaves the - * process for a CLI whose wall clock is the host's — seconds on a good day, tens of them on a - * slow network — and the short cache windows below mean almost every page visit pays that - * clock again. The last success per key is therefore held a while longer: a read inside the - * window answers with it at once and refreshes the cache in the background, so the next read - * is fresh without anyone having waited on it. - * - * Correctness leans on the epochs: an explicit refresh or a mutation bumps them, the epoch is - * part of every key, and a held answer under the old key is simply never asked for again — so - * "give me truly fresh" still means exactly that. + * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path. + * Explicit refreshes and mutations still strand held values through the reference epoch. */ - const staleWhileRevalidate = (staleFor: Duration.Duration, capacity: number) => { - const staleMs = Duration.toMillis(staleFor); - const held = new Map(); - const record = (key: string, value: A) => + const staleDiff = (() => { + const staleMs = Duration.toMillis(DIFF_STALE_WINDOW); + const held = new Map(); + const record = (key: string, value: PullRequestDiffResult) => Effect.map(Clock.currentTimeMillis, (at) => { held.delete(key); - if (held.size >= capacity) { + if (held.size >= DIFF_CACHE_CAPACITY) { const oldest = held.keys().next().value; if (oldest !== undefined) held.delete(oldest); } held.set(key, { at, value }); }); - return (key: string, read: Effect.Effect): Effect.Effect => { + return (key: string, read: Effect.Effect) => { const recorded = read.pipe(Effect.tap((value) => record(key, value))); return Effect.flatMap(Clock.currentTimeMillis, (now) => { const snapshot = held.get(key); @@ -1860,7 +1843,7 @@ export const make = Effect.gen(function* () { return Effect.sync(() => runFork(Effect.ignore(recorded))).pipe(Effect.as(snapshot.value)); }); }; - }; + })(); // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the // epoch strands every entry made under the old one — no enumerating a cache whose keys @@ -1946,10 +1929,6 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_CACHE_TTL : Duration.zero), }, ); - const staleList = staleWhileRevalidate( - LIST_STALE_WINDOW, - LIST_CACHE_CAPACITY, - ); const list: PullRequestService["Service"]["list"] = (input) => { const key = JSON.stringify([ listingsEpoch, @@ -1976,7 +1955,7 @@ export const make = Effect.gen(function* () { ? null : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)), ]); - return staleList(key, Cache.get(listCache, key)); + return Cache.get(listCache, key); }; const detailCache = yield* Cache.makeWith( @@ -1989,13 +1968,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); - const staleDetail = staleWhileRevalidate( - DETAIL_STALE_WINDOW, - DETAIL_CACHE_CAPACITY, - ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); - return staleDetail(key, Cache.get(detailCache, key)); + return Cache.get(detailCache, key); }; const activityCache = yield* Cache.makeWith( @@ -2008,13 +1983,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); - const staleActivity = staleWhileRevalidate( - DETAIL_STALE_WINDOW, - DETAIL_CACHE_CAPACITY, - ); const activity: PullRequestService["Service"]["activity"] = (input) => { const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); - return staleActivity(key, Cache.get(activityCache, key)); + return Cache.get(activityCache, key); }; const diffCache = yield* Cache.makeWith( @@ -2044,10 +2015,6 @@ export const make = Effect.gen(function* () { }, }, ); - const staleDiff = staleWhileRevalidate( - DIFF_STALE_WINDOW, - DIFF_CACHE_CAPACITY, - ); const diff: PullRequestService["Service"]["diff"] = (input) => { const key = JSON.stringify([ refEpoch(input), @@ -2076,10 +2043,6 @@ export const make = Effect.gen(function* () { // shares between clients like every other read. Refs are sorted so one page's worth of rows // is one key however the client assembled them, and the listings epoch rides along so the // refresh that forgets the listing forgets its decorations with it. - const staleListStats = staleWhileRevalidate( - LIST_STALE_WINDOW, - LIST_STATS_CACHE_CAPACITY, - ); const listStats: PullRequestService["Service"]["listStats"] = (input) => { if (input.refs.length === 0) return Effect.succeed({ stats: [] }); const key = JSON.stringify([ @@ -2090,7 +2053,7 @@ export const make = Effect.gen(function* () { `${left[0]} ${left[1]} ${left[2]}`.localeCompare(`${right[0]} ${right[1]} ${right[2]}`), ), ]); - return staleListStats(key, Cache.get(listStatsCache, key)); + return Cache.get(listStatsCache, key); }; const invalidate: PullRequestService["Service"]["invalidate"] = (input) => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f339ff84fae9..2fc272db1685 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -102,7 +102,11 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; +import { + isThreadDetailEvent, + resolveAvailableEditorsForConfig, + resolveFileManagerRevealKindForConfig, +} from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -114,6 +118,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; +import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -151,6 +157,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -386,6 +393,7 @@ const buildAppUnderTest = (options?: { layers?: { keybindings?: Partial; providerRegistry?: Partial; + providerService?: Partial; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -402,6 +410,7 @@ const buildAppUnderTest = (options?: { >; terminalManager?: Partial; orchestrationEngine?: Partial; + analyticsService?: Partial; projectionSnapshotQuery?: Partial; checkpointDiffQuery?: Partial; browserTraceCollector?: Partial; @@ -627,18 +636,24 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), - refresh: () => Effect.succeed([]), - refreshInstance: () => Effect.succeed([]), - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), - ), - setProviderMaintenanceActionState: () => Effect.succeed([]), - streamChanges: Stream.empty, - ...options?.layers?.providerRegistry, - }), + Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.providerRegistry, + }), + Layer.mock(ProviderService.ProviderService)({ + uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"), + ...options?.layers?.providerService, + }), + ), ), Layer.provide( Layer.mock(ServerSettings.ServerSettingsService)({ @@ -654,6 +669,7 @@ const buildAppUnderTest = (options?: { Layer.mergeAll( Layer.mock(ExternalLauncher.ExternalLauncher)({ resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined), ...options?.layers?.externalLauncher, }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ @@ -833,6 +849,13 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), Layer.provide(UsageService.layerTest), + Layer.provide( + Layer.mock(AnalyticsService.AnalyticsService)({ + record: () => Effect.void, + flush: Effect.void, + ...options?.layers?.analyticsService, + }), + ), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -4013,10 +4036,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); assert.equal(response.auth.policy, "desktop-managed-local"); assert.equal(response.shellResumeCompletionMarker, true); + assert.isUndefined(response.shellRevealInFileManager); + assert.isUndefined(response.shellRevealInFileManagerKind); assert.equal(response.threadResumeCompletionMarker, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("advertises the usable file manager and its reveal label", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + externalLauncher: { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), + }, + }, + }); + + const { cookie } = yield* bootstrapBrowserSession(); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.deepEqual(response.availableEditors, ["file-manager"]); + assert.equal(response.shellRevealInFileManager, true); + assert.equal(response.shellRevealInFileManagerKind, "file-explorer"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); @@ -4034,6 +4085,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); + it.effect("does not block server config when file manager reveal discovery never resolves", () => + Effect.gen(function* () { + const discoveryInterrupted = yield* Deferred.make(); + const responseFiber = yield* resolveFileManagerRevealKindForConfig( + Effect.never.pipe( + Effect.onInterrupt(() => Deferred.succeed(discoveryInterrupted, undefined)), + ), + ).pipe(Effect.forkChild); + + yield* TestClock.adjust(Duration.seconds(5)); + + const revealKind = yield* Fiber.join(responseFiber); + yield* Deferred.await(discoveryInterrupted); + assert.isUndefined(revealKind); + }), + ); + it.effect( "rejects websocket rpc handshake when a session token is only provided via query string", () => @@ -4443,6 +4511,114 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("uploads Codex thread feedback through websocket rpc", () => + Effect.gen(function* () { + const input = { + threadId: ThreadId.make("thread-feedback"), + reason: "The agent stopped early.", + }; + const uploadFeedback = vi.fn( + () => Effect.succeed({ feedbackId: "codex-thread-feedback" }), + ); + yield* buildAppUnderTest({ + layers: { + providerService: { uploadFeedback }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.providerUploadFeedback](input)), + ); + + assert.deepStrictEqual(response, { feedbackId: "codex-thread-feedback" }); + assert.deepStrictEqual(uploadFeedback.mock.calls, [[input]]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("uploads image bytes through a signed URL issued by websocket rpc", () => + Effect.gen(function* () { + const config = yield* buildAppUnderTest(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const wsUrl = yield* getWsServerUrl("/ws"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const issued = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }); + const rejected = yield* HttpClient.post(issued.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3]), "image/png"), + }); + assert.equal(rejected.status, 400); + + const response = yield* HttpClient.post(issued.relativeUrl, { + headers: { origin: crossOriginClientOrigin }, + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"), + }); + assert.equal(response.status, 204); + assertBrowserApiCorsResponseHeaders(response.headers); + + const attachmentPath = path.join(config.attachmentsDir, `${issued.attachmentId}.png`); + assert.isTrue(yield* fileSystem.exists(attachmentPath)); + + yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: issued.attachmentId }); + assert.isFalse(yield* fileSystem.exists(attachmentPath)); + + const streamed = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + name: "streamed.png", + mimeType: "image/png", + sizeBytes: 6, + }); + const streamedResponse = yield* HttpClient.post(streamed.relativeUrl, { + body: HttpBody.stream(Stream.make(new Uint8Array([1, 2, 3, 4, 5, 6])), "image/png"), + }); + assert.equal(streamedResponse.status, 204); + yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId }); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("keeps feedback errors structured across websocket rpc", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-feedback-failure"); + yield* buildAppUnderTest({ + layers: { + providerService: { + uploadFeedback: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "feedback/upload", + detail: "private provider detail", + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.providerUploadFeedback]({ threadId }).pipe(Effect.flip), + ), + ); + + assert.strictEqual(error._tag, "ProviderUploadFeedbackError"); + if (error._tag === "ProviderUploadFeedbackError") { + assert.strictEqual(error.threadId, threadId); + assert.strictEqual(error.message, `Failed to upload feedback for thread ${threadId}.`); + assert.isDefined(error.cause); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("shares one preview automation broker across websocket sessions", () => Effect.scoped( Effect.gen(function* () { @@ -4520,6 +4696,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { + const path = yield* Path.Path; const providers = [ { instanceId: ProviderInstanceId.make("codex"), @@ -4573,7 +4750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); - assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); + assert.equal(path.basename(first.config.observability.logsDirectoryPath), "logs"); assert.equal(first.config.observability.localTracingEnabled, true); assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); assert.equal(first.config.observability.otlpTracesEnabled, true); @@ -5035,6 +5212,101 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("records thread analytics only after a client command succeeds", () => + Effect.gen(function* () { + const effects: string[] = []; + const analyticsProperties: Array> | undefined> = []; + const failedCommandId = CommandId.make("cmd-thread-create-failed"); + + yield* buildAppUnderTest({ + layers: { + analyticsService: { + record: (event, properties) => + Effect.sync(() => { + effects.push(`analytics:${event}`); + analyticsProperties.push(properties); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => effects.push(`dispatch:${command.commandId}`)).pipe( + Effect.flatMap(() => + command.commandId === failedCommandId + ? Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "thread creation failed", + }), + ) + : Effect.succeed({ sequence: 1 }), + ), + ), + }, + }, + }); + + const createThreadCommand = (commandId: CommandId, threadId: ThreadId) => + ({ + type: "thread.create", + commandId, + threadId, + projectId: defaultProjectId, + title: "Analytics test", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }) as const; + + const wsUrl = yield* getWsServerUrl( + "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro", + ); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const failed = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + createThreadCommand(failedCommandId, ThreadId.make("thread-create-failed")), + ).pipe(Effect.result); + + assert.equal(failed._tag, "Failure"); + assert.deepEqual(effects, [ + "analytics:client.connected", + "dispatch:cmd-thread-create-failed", + ]); + + const succeeded = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + createThreadCommand( + CommandId.make("cmd-thread-create-succeeded"), + ThreadId.make("thread-create-succeeded"), + ), + ); + + assert.equal(succeeded.sequence, 1); + }), + ), + ); + + assert.deepEqual(effects, [ + "analytics:client.connected", + "dispatch:cmd-thread-create-failed", + "dispatch:cmd-thread-create-succeeded", + "analytics:client.thread.started", + ]); + assert.deepEqual(analyticsProperties, [ + { + surface: "mobile", + appVersion: "1.2.3", + os: "iOS", + osMajorVersion: 18, + deviceModel: "iPhone 15 Pro", + }, + { surface: "mobile", appVersion: "1.2.3" }, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -7909,12 +8181,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("cleans up created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const createWorktree = vi.fn( (_: Parameters[0]) => Effect.die(new Error("worktree exploded")), ); - yield* buildAppUnderTest({ + const config = yield* buildAppUnderTest({ layers: { gitVcsDriver: { createWorktree, @@ -7930,16 +8204,127 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }); + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + let pendingAttachmentId: string | undefined; + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const upload = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }); + pendingAttachmentId = upload.attachmentId; + const uploadResponse = yield* HttpClient.post(upload.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"), + }); + assert.equal(uploadResponse.status, 204); + + return yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-defect"), + threadId: ThreadId.make("thread-bootstrap-defect"), + message: { + messageId: MessageId.make("msg-bootstrap-defect"), + role: "user", + text: "hello", + attachments: [ + { + type: "image", + id: upload.attachmentId, + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 6, + }, + ], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: false, + }, + createdAt, + }); + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "worktree exploded"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.delete"], + ); + assert.isDefined(pendingAttachmentId); + assert.isTrue( + yield* fileSystem.exists(path.join(config.attachmentsDir, `${pendingAttachmentId}.png`)), + ); + assert.deepEqual(yield* fileSystem.readDirectory(config.attachmentsDir), [ + `${pendingAttachmentId}.png`, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not report a deleted bootstrap thread when cleanup fails", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("worktree exploded")), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + if (command.type === "thread.delete") { + return Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "thread cleanup exploded", + }), + ); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + readEvents: () => Stream.empty, + }, + }, + }); + const createdAt = "2026-01-01T00:00:00.000Z"; const wsUrl = yield* getWsServerUrl("/ws"); const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", - commandId: CommandId.make("cmd-bootstrap-turn-start-defect"), - threadId: ThreadId.make("thread-bootstrap-defect"), + commandId: CommandId.make("cmd-bootstrap-turn-start-cleanup-defect"), + threadId: ThreadId.make("thread-bootstrap-cleanup-defect"), message: { - messageId: MessageId.make("msg-bootstrap-defect"), + messageId: MessageId.make("msg-bootstrap-cleanup-defect"), role: "user", text: "hello", attachments: [], @@ -7973,6 +8358,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); assert.include(result.failure.message, "worktree exploded"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined); assert.deepEqual( dispatchedCommands.map((command) => command.type), ["thread.create", "thread.delete"], diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 46fad5260121..b0ccf77ec1b2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -13,6 +13,7 @@ import * as ServerConfig from "./config.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, + attachmentUploadRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -31,6 +32,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; @@ -144,7 +146,10 @@ const PtyAdapterLive = Layer.unwrap( }), ); -const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)); +const ServerSettingsLayerLive = ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(SqlitePersistenceLayerLive), +); const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( Layer.provide(ResourceMonitorBinary.layer), @@ -394,7 +399,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `ProviderService` (canonical stream, written after event normalization). // Provided once at the runtime level so every consumer sees the same // logger instances. - Layer.provideMerge(ProviderEventLoggers.layer), + // `ModelManifest.layer` is the legacy-model classification data, refreshed + // from the repo's `model-manifest.json` on `main` and applied by the + // Codex/Claude drivers. + Layer.provideMerge(Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer)), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and @@ -460,6 +468,7 @@ export const makeRoutesLayer = Layer.mergeAll( ), otlpTracesProxyRouteLayer, assetRouteLayer, + attachmentUploadRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts new file mode 100644 index 000000000000..3c52b4c43548 --- /dev/null +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -0,0 +1,299 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + type OrchestrationCommand, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { OrchestrationCommandInvariantError } from "./orchestration/Errors.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderSessionDirectoryPersistenceError } from "./provider/Errors.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; +import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; + +const providerInstanceId = ProviderInstanceId.make("codex"); +const updatedAt = "2026-08-20T12:00:00.000Z"; + +const makeThread = ( + id: string, + status: "starting" | "running" | "ready" | "stopped" | "error", + activeTurnId: TurnId | null = null, + archivedAt: string | null = null, +) => ({ + id: ThreadId.make(id), + archivedAt, + deletedAt: null, + session: { + threadId: ThreadId.make(id), + status, + providerName: "codex" as const, + providerInstanceId, + runtimeMode: "full-access" as const, + activeTurnId, + lastError: null, + updatedAt, + }, +}); + +const makeProviderService = (liveThreadIds: ReadonlyArray = []) => + ({ + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed(liveThreadIds.map((threadId) => ({ threadId }) as never)), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + rollbackConversationTo: () => Effect.die("unused"), + discardTransientThread: () => Effect.die("unused"), + uploadFeedback: () => Effect.die("unused"), + streamEvents: Stream.empty, + }) satisfies ProviderService.ProviderService["Service"]; + +const queryWithThreads = (threads: ReadonlyArray>) => + ({ + getCommandReadModel: () => Effect.succeed({ threads } as never), + }) as unknown as ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + +const runReconciliation = (input: { + readonly threads: ReadonlyArray>; + readonly liveThreadIds?: ReadonlyArray; + readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + readonly dispatch: OrchestrationEngine.OrchestrationEngineService["Service"]["dispatch"]; +}) => + ServerRuntimeStartup.reconcileProviderSessions.pipe( + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + queryWithThreads(input.threads), + ), + Effect.provideService( + ProviderService.ProviderService, + makeProviderService(input.liveThreadIds), + ), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: input.dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Effect.provide(NodeServices.layer), + ); + +it.effect("reconciles multiple active and archived orphans but skips live sessions", () => { + const starting = makeThread("thread-starting", "starting"); + const running = makeThread("thread-running", "running", TurnId.make("turn-running")); + const staleActiveTurn = makeThread( + "thread-stale-active-turn", + "ready", + TurnId.make("turn-stale-active"), + ); + const archived = makeThread( + "thread-archived", + "running", + TurnId.make("turn-archived"), + updatedAt, + ); + const live = makeThread("thread-live", "running", TurnId.make("turn-live")); + const settled = makeThread("thread-ready", "ready"); + const dispatched: OrchestrationCommand[] = []; + const bindingReads: ThreadId[] = []; + const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; + + return runReconciliation({ + threads: [starting, running, staleActiveTurn, archived, live, settled], + liveThreadIds: [live.id], + directory: { + getBinding: (candidate) => + Effect.sync(() => bindingReads.push(candidate)).pipe( + Effect.as( + Option.some({ + threadId: candidate, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running" as const, + resumeCursor: { cursor: candidate }, + runtimePayload: { activeTurnId: "stale", unrelated: candidate }, + }), + ), + ), + upsert: (binding) => Effect.sync(() => upserts.push(binding)), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => + Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + const orphanIds = [starting.id, running.id, staleActiveTurn.id, archived.id]; + assert.deepStrictEqual(bindingReads, orphanIds); + assert.deepStrictEqual( + dispatched.map((command) => command.type === "thread.session.set" && command.threadId), + orphanIds, + ); + assert.deepStrictEqual( + dispatched.map((command) => + command.type === "thread.session.set" + ? { + status: command.session.status, + activeTurnId: command.session.activeTurnId, + } + : null, + ), + orphanIds.map(() => ({ status: "error" as const, activeTurnId: null })), + ); + assert.equal(upserts.length, orphanIds.length); + for (const binding of upserts) { + assert.equal(binding.status, "stopped"); + assert.deepStrictEqual(binding.runtimePayload, { activeTurnId: null }); + assert.deepStrictEqual(binding.resumeCursor, { cursor: binding.threadId }); + } + }), + ), + ); +}); + +it.effect( + "settles projections when directory bindings are absent, corrupt, or fail to upsert", + () => { + const absent = makeThread("thread-binding-absent", "starting"); + const corrupt = makeThread("thread-binding-corrupt", "running"); + const upsertFailure = makeThread("thread-binding-upsert-failure", "running"); + const dispatched: OrchestrationCommand[] = []; + const corruptFailure = new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.getBinding", + detail: "corrupt persisted binding", + }); + const writeFailure = new ProviderSessionDirectoryPersistenceError({ + operation: "ProviderSessionDirectory.upsert", + detail: "failed binding write", + }); + + return runReconciliation({ + threads: [absent, corrupt, upsertFailure], + directory: { + getBinding: (candidate) => + candidate === absent.id + ? Effect.succeed(Option.none()) + : candidate === corrupt.id + ? Effect.fail(corruptFailure) + : Effect.succeed( + Option.some({ + threadId: candidate, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + }), + ), + upsert: () => Effect.fail(writeFailure), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => + Effect.sync(() => dispatched.push(command)).pipe( + Effect.as({ sequence: dispatched.length }), + ), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + assert.deepStrictEqual( + dispatched.map((command) => command.type === "thread.session.set" && command.threadId), + [absent.id, corrupt.id, upsertFailure.id], + ); + }), + ), + ); + }, +); + +it.effect("retries failed projections and continues after a persistent failure", () => { + const transient = makeThread("thread-dispatch-transient-failure", "running"); + const persistent = makeThread("thread-dispatch-persistent-failure", "running"); + const later = makeThread("thread-dispatch-success", "running"); + const attempted: ThreadId[] = []; + let transientAttempts = 0; + const failure = new OrchestrationCommandInvariantError({ + commandType: "thread.session.set", + detail: "simulated startup reconciliation failure", + }); + + return runReconciliation({ + threads: [transient, persistent, later], + directory: { + getBinding: () => Effect.succeed(Option.none()), + upsert: () => Effect.void, + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }, + dispatch: (command) => { + if (command.type !== "thread.session.set") { + return Effect.die("unexpected command"); + } + attempted.push(command.threadId); + if (command.threadId === transient.id && transientAttempts++ === 0) { + return Effect.fail(failure); + } + return command.threadId === persistent.id + ? Effect.fail(failure) + : Effect.succeed({ sequence: attempted.length }); + }, + }).pipe( + Effect.tap(() => + Effect.sync(() => + assert.deepStrictEqual(attempted, [ + transient.id, + transient.id, + persistent.id, + persistent.id, + later.id, + ]), + ), + ), + ); +}); + +it.effect("does not fail startup when the live provider session inventory cannot be read", () => { + let queried = false; + return ServerRuntimeStartup.reconcileProviderSessions.pipe( + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => + Effect.sync(() => { + queried = true; + return { threads: [] } as never; + }), + } as unknown as ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]), + Effect.provideService(ProviderService.ProviderService, { + ...makeProviderService(), + listSessions: () => Effect.die("provider inventory unavailable"), + }), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { + getBinding: () => Effect.die("unused"), + upsert: () => Effect.die("unused"), + getProvider: () => Effect.die("unused"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Effect.provide(NodeServices.layer), + Effect.tap(() => Effect.sync(() => assert.equal(queried, false))), + ); +}); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index f19be0f3a71e..b824e21b066a 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -7,6 +7,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -33,6 +34,8 @@ import * as ServerSettings from "./serverSettings.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -306,6 +309,89 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) Effect.withSpan(`server.startup.${phase}`), ); +const ORPHANED_PROVIDER_SESSION_ERROR = + "Provider session did not survive a server restart. Send a new message to continue."; + +export const reconcileProviderSessions = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const providerService = yield* ProviderService.ProviderService; + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + + const liveThreadIds = new Set( + (yield* providerService.listSessions()).map((session) => session.threadId), + ); + const { threads } = yield* query.getCommandReadModel(); + const orphanedThreads = threads.filter( + (thread) => + thread.session !== null && + (thread.session.status === "starting" || + thread.session.status === "running" || + thread.session.activeTurnId !== null) && + !liveThreadIds.has(thread.id), + ); + + for (const thread of orphanedThreads) { + const session = thread.session; + if (session === null) { + continue; + } + yield* Effect.gen(function* () { + const binding = yield* directory.getBinding(thread.id); + if (Option.isSome(binding)) { + yield* directory.upsert({ + ...binding.value, + status: "stopped", + runtimePayload: { activeTurnId: null }, + }); + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to reconcile orphaned provider session directory binding", { + threadId: thread.id, + cause, + }), + ), + ); + + yield* Effect.gen(function* () { + const reconciledAt = DateTime.formatIso(yield* DateTime.now); + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId: thread.id, + session: { + ...session, + status: "error", + activeTurnId: null, + lastError: ORPHANED_PROVIDER_SESSION_ERROR, + updatedAt: reconciledAt, + }, + createdAt: reconciledAt, + }); + }).pipe( + Effect.retry({ times: 1 }), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to settle orphaned provider session projection", { + threadId: thread.id, + cause, + }), + ), + ); + } +}).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("provider session startup reconciliation failed", { cause }), + ), +); + interface StartupOptions { readonly activate?: Effect.Effect; readonly awaitAuxiliaryParked?: Effect.Effect; @@ -370,6 +456,8 @@ export const make = (options?: StartupOptions) => }), ); + yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions); + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4bc14cad81f4..5e60d225144b 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_SERVER_SETTINGS, ProviderDriverKind, ProviderInstanceId, + resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, } from "@t3tools/contracts"; @@ -16,8 +17,10 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; +import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); @@ -26,6 +29,7 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const makeServerSettingsLayer = () => ServerSettingsModule.layer.pipe( Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), Layer.provideMerge( Layer.fresh( ServerConfig.layerTest(process.cwd(), { @@ -47,6 +51,27 @@ const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) }), ); +const recordProviderUsage = (provider: string, instanceId: string | null = provider) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_instance_id, + updated_at + ) + VALUES ( + ${`thread-${instanceId ?? provider}`}, + ${"ready"}, + ${provider}, + ${instanceId}, + ${"2026-08-25T00:00:00.000Z"} + ) + `; + }); + it.layer(NodeServices.layer)("server settings", (it) => { it.effect("preserves context when reading a provider environment secret fails", () => { const platformCause = PlatformError.systemError({ @@ -67,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); const settingsLayer = ServerSettingsModule.layer.pipe( Layer.provide(makeFailingSecretStoreLayer(cause)), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), Layer.provideMerge(configLayer), ); @@ -92,6 +118,23 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(settingsLayer)); }); + it.effect("identifies provider history query failures", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const sql = yield* SqlClient.SqlClient; + yield* sql`DROP TABLE projection_thread_sessions`; + + const error = yield* Effect.flip(serverSettings.getSettings); + + assert.deepInclude(error, { + _tag: "ServerSettingsError", + operation: "read-provider-history", + settingsPath: serverConfig.settingsPath, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("decodes nested settings patches", () => Effect.gen(function* () { assert.deepEqual( @@ -191,6 +234,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: ["claude-custom"], launchArgs: "", promptSuggestions: true, + autoCompactWindow: "", }); assert.deepEqual( next.textGenerationModelSelection, @@ -488,6 +532,251 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("enables previously used providers from sparse settings files", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providers":{"opencode":{"serverUrl":"http://127.0.0.1:4096"}}}', + ); + yield* recordProviderUsage("opencode"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isTrue(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves existing provider instances without explicit enabled flags", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}},"grok":{"driver":"grok","config":{}},"opencode_work":{"driver":"opencode","config":{"serverUrl":"http://127.0.0.1:4096"}},"opencode_unused":{"driver":"opencode","config":{}}}}', + ); + yield* recordProviderUsage("cursor", "cursor_work"); + yield* recordProviderUsage("grok", null); + yield* recordProviderUsage("opencode", "opencode_work"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.cursor.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled); + const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; + assert.isDefined(unused); + assert.isFalse(resolveProviderInstanceEnabled(unused)); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves explicit provider disables in existing settings files", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providers":{"grok":{"enabled":false},"opencode":{"enabled":false},"cursor":{"enabled":false}},"providerInstances":{"grok":{"driver":"grok","enabled":false,"config":{}},"opencode":{"driver":"opencode","config":{"enabled":false}},"cursor":{"driver":"cursor","enabled":false,"config":{}}}}', + ); + yield* recordProviderUsage("grok"); + yield* recordProviderUsage("opencode"); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("keeps unused providers disabled in existing sparse settings files", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves provider history when no settings file exists", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* recordProviderUsage("grok"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves provider history when the settings file is invalid", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json"); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.cursor.enabled); + assert.isFalse(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves valid provider flags when another settings field is invalid", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"addProjectBaseDirectory":42,"providers":{"cursor":{"enabled":false},"grok":{"enabled":true}}}', + ); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.cursor.enabled); + assert.isTrue(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("restores providers from persisted runtime sessions", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + status, + last_seen_at + ) + VALUES ( + ${"thread-opencode-runtime"}, + ${"opencode"}, + ${"opencode"}, + ${"opencode"}, + ${"ready"}, + ${"2026-08-25T00:00:00.000Z"} + ) + `; + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isTrue(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("persists explicit disables after a provider has been used", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* recordProviderUsage("grok"); + + assert.isTrue((yield* serverSettings.getSettings).providers.grok.enabled); + + const settings = yield* serverSettings.updateSettings({ + providers: { grok: { enabled: false } }, + }); + assert.isFalse(settings.providers.grok.enabled); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.isFalse(JSON.parse(raw).providers.grok.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("persists explicit provider enables before their first use", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + + yield* serverSettings.updateSettings({ + providers: { + cursor: { enabled: true }, + grok: { enabled: true }, + opencode: { enabled: true }, + }, + }); + yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw); + assert.isTrue(persisted.providers.cursor.enabled); + assert.isTrue(persisted.providers.grok.enabled); + assert.isTrue(persisted.providers.opencode.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("keeps optional providers disabled after a new installation writes settings", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + + const initial = yield* serverSettings.getSettings; + assert.isFalse(initial.providers.grok.enabled); + assert.isFalse(initial.providers.opencode.enabled); + assert.isFalse(initial.providers.cursor.enabled); + + const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: "~/Development", + providerInstances: { + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), + config: {}, + }, + }, + }); + + assert.isFalse(next.providers.grok.enabled); + assert.isFalse(next.providers.opencode.enabled); + assert.isFalse(next.providers.cursor.enabled); + const grok = next.providerInstances[ProviderInstanceId.make("grok")]; + assert.isDefined(grok); + assert.isFalse(resolveProviderInstanceEnabled(grok)); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw); + assert.isFalse(persisted.providers.cursor.enabled); + assert.isFalse(persisted.providers.grok.enabled); + assert.isFalse(persisted.providers.opencode.enabled); + assert.isUndefined(persisted.providerInstances.grok.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("folds a legacy in-config enabled flag into the envelope on load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -583,6 +872,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: [], launchArgs: "", promptSuggestions: true, + autoCompactWindow: "", }); assert.deepEqual(next.providers.opencode, { // OpenCode is disabled by default; this update only touches paths. @@ -635,7 +925,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); - it.effect("writes only non-default server settings to disk", () => + it.effect("writes non-default settings and explicit optional provider defaults to disk", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const serverConfig = yield* ServerConfig.ServerConfig; @@ -672,7 +962,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + cursor: { + enabled: false, + }, + grok: { + enabled: false, + }, opencode: { + enabled: false, serverUrl: "http://127.0.0.1:4096", serverPassword: "secret-password", }, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 1bf37335271b..5a8650b7e405 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -42,6 +42,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; @@ -230,6 +231,66 @@ export const layerTest = (overrides: DeepPartial = {}) => const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); +const PersistedOptionalProviderSettings = Schema.Struct({ + providers: Schema.optionalKey( + Schema.Struct({ + cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + }), + ), +}); +const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit( + fromLenientJson(PersistedOptionalProviderSettings), +); + +function restoreUsedProviders( + settings: ServerSettings, + persisted: typeof PersistedOptionalProviderSettings.Type, + providerHistory: ReadonlyArray<{ + readonly providerName: string; + readonly providerInstanceId: string | null; + }>, +): ServerSettings { + const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName)); + const usedProviderInstances = new Set( + providerHistory.map( + ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName, + ), + ); + const providerInstances = Object.fromEntries( + Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ + instanceId, + instance.enabled === undefined && + (instance.driver === "cursor" || + instance.driver === "grok" || + instance.driver === "opencode") && + usedProviderInstances.has(instanceId) + ? { ...instance, enabled: true } + : instance, + ]), + ); + + return { + ...settings, + providers: { + ...settings.providers, + cursor: { + ...settings.providers.cursor, + enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"), + }, + grok: { + ...settings.providers.grok, + enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), + }, + opencode: { + ...settings.providers.opencode, + enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"), + }, + }, + providerInstances, + }; +} function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) @@ -265,6 +326,17 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "textGenerationModelSelection", ]); +// Preserve both enabled states because provider history cannot recover a new opt-in. +const PERSISTED_SERVER_SETTINGS_DEFAULTS = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, + grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, + opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, + }, +}; + function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { return Equal.equals(current, defaults) ? undefined : current; @@ -304,6 +376,7 @@ const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const secretStore = yield* ServerSecretStore.ServerSecretStore; + const sql = yield* SqlClient.SqlClient; const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; const changesPubSub = yield* PubSub.unbounded(); @@ -338,21 +411,59 @@ const make = Effect.gen(function* () { ); const loadSettingsFromDisk = Effect.gen(function* () { - if (!(yield* readConfigExists)) { - return DEFAULT_SERVER_SETTINGS; + let settings = DEFAULT_SERVER_SETTINGS; + let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + + if (yield* readConfigExists) { + const raw = yield* readRawConfig; + const decoded = decodeServerSettingsJsonExit(raw); + const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw); + if (persistedSettings._tag === "Success") { + persisted = persistedSettings.value; + } + if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { + const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + if (failure._tag === "Failure") { + yield* Effect.logWarning("failed to parse settings.json, using defaults", { + path: settingsPath, + issues: Cause.pretty(failure.cause), + cause: failure.cause, + }); + } + } else { + settings = decoded.value; + } } - const raw = yield* readRawConfig; - const decoded = decodeServerSettingsJsonExit(raw); - if (decoded._tag === "Failure") { - yield* Effect.logWarning("failed to parse settings.json, using defaults", { - path: settingsPath, - issues: Cause.pretty(decoded.cause), - cause: decoded.cause, - }); - return DEFAULT_SERVER_SETTINGS; - } - return foldProviderInstanceEnabledFlags(decoded.value); + const providerHistory = yield* sql<{ + readonly providerName: string; + readonly providerInstanceId: string | null; + }>` + SELECT DISTINCT + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId" + FROM projection_thread_sessions + WHERE provider_name IN ('cursor', 'grok', 'opencode') + UNION + SELECT DISTINCT + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId" + FROM provider_session_runtime + WHERE provider_name IN ('cursor', 'grok', 'opencode') + `.pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-provider-history", + cause, + }), + ), + ); + + return foldProviderInstanceEnabledFlags( + restoreUsedProviders(settings, persisted, providerHistory), + ); }); const settingsCache = yield* Cache.make({ @@ -528,7 +639,7 @@ const make = Effect.gen(function* () { const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, + stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, ); return yield* writeFileStringAtomically({ diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 00fb4e4106df..68f3c346759d 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -27,6 +27,7 @@ import { SERVICE_STATE_FILE, SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; +import { isEntrypoint } from "./entrypoint.ts"; const HANDOFF_DELAY_MS = 2_000; const PREPARED_TIMEOUT_MS = 120_000; @@ -611,7 +612,13 @@ async function main(): Promise { await new Launcher(baseDir, state).run(); } -if (import.meta.main) { +if ( + isEntrypoint({ + moduleUrl: import.meta.url, + entryPath: process.argv[1], + runtimeMain: import.meta.main, + }) +) { main().catch((cause: unknown) => { const error = cause instanceof Error ? cause : new Error(String(cause)); process.stderr.write(`[service-launcher] ${error.message}\n`); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index b9ef992122ae..6cf4400c62eb 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -289,6 +289,10 @@ export class GitVcsDriver extends Context.Service< ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly resolveDefaultBranchName: ( + cwd: string, + remoteName: string, + ) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( @@ -306,6 +310,10 @@ export class GitVcsDriver extends Context.Service< readonly removeWorktree: ( input: VcsRemoveWorktreeInput, ) => Effect.Effect; + /** Drops worktree admin entries whose directory is already gone (`git worktree prune`). */ + readonly pruneWorktrees: (input: { + readonly cwd: string; + }) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 66dc7b96a73e..8a820aa81941 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -739,13 +739,13 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const pathService = yield* Path.Path; - const missingWorktree = pathService.join(cwd, "missing-worktree"); + const fileSystem = yield* FileSystem.FileSystem; + const notAWorktree = pathService.join(cwd, "not-a-worktree"); + yield* fileSystem.makeDirectory(notAWorktree); const driver = yield* GitVcsDriver.GitVcsDriver; yield* driver.initRepo({ cwd }); - const error = yield* driver - .removeWorktree({ cwd, path: missingWorktree }) - .pipe(Effect.flip); + const error = yield* driver.removeWorktree({ cwd, path: notAWorktree }).pipe(Effect.flip); assert.deepInclude(error, { _tag: "GitCommandError", @@ -755,9 +755,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { cwd, }); assert.notProperty(error, "cause"); + assert.notProperty(error, "stderr"); assert.notInclude(error.detail, "Git command failed in"); }), ); + + it.effect("treats removing an already-gone worktree as a no-op", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const pathService = yield* Path.Path; + const missingWorktree = pathService.join(cwd, "missing-worktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + + yield* driver.removeWorktree({ cwd, path: missingWorktree }); + }), + ); }); describe("review diff previews", () => { @@ -1374,6 +1387,92 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("checks out submodules in a new worktree", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + // Git refuses `file:` submodule transports by default (CVE-2022-39253) + // and ignores repo-level config for it, so a local fixture needs the + // env allowance. Real submodules are https/ssh and need none of this. + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // A real submodule: `git worktree add` leaves these empty, which is + // what silently strips shared tooling out of every new worktree. + const submoduleRepo = yield* makeTmpDir("git-submodule-"); + yield* initRepoWithCommit(submoduleRepo); + yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n"); + yield* git(submoduleRepo, ["add", "."]); + yield* git(submoduleRepo, ["commit", "-m", "shared"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "submodule-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/submodules", + }); + + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")), + true, + ); + }), + ); + + it.effect("still creates the worktree when submodule checkout fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + // Points at a repository that does not exist, so the checkout fails the + // way an unreachable private remote would. Creation must still succeed. + yield* writeTextFile( + cwd, + ".gitmodules", + '[submodule "missing"]\n\tpath = missing\n\turl = /nonexistent/repo.git\n', + ); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add unreachable submodule"]); + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "broken-submodule-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/broken-submodules", + }); + + assert.equal(created.worktree.path, worktreePath); + assert.equal(yield* fileSystem.exists(worktreePath), true); + }), + ); + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1401,6 +1500,57 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(yield* fileSystem.exists(worktreePath), false); }), ); + + it.effect("removes the same worktree path twice without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "shared"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/shared", + }); + + // Two threads can record the same worktree path; the second delete + // must be a no-op instead of exit 128. + yield* driver.removeWorktree({ cwd, path: worktreePath }); + yield* driver.removeWorktree({ cwd, path: worktreePath }); + }), + ); + + it.effect("prunes stale registrations when removing an already-gone worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreesRoot = yield* makeTmpDir("git-worktrees-"); + const stalePath = pathService.join(worktreesRoot, "stale"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: stalePath, + refName: initialBranch, + newRefName: "feature/stale", + }); + // Delete the directory behind git's back so the registration goes stale. + yield* fileSystem.remove(stalePath, { recursive: true }); + + yield* driver.removeWorktree({ + cwd, + path: pathService.join(worktreesRoot, "never-registered"), + }); + + const registered = yield* git(cwd, ["worktree", "list", "--porcelain"]); + assert.notInclude(registered, "stale"); + }), + ); }); describe("remote operations", () => { @@ -1687,6 +1837,111 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("publishes a branch tracking its base under its own name, not the base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "dev"]); + yield* git(cwd, ["push", "-u", "origin", "dev"]); + const devSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/x", + upstreamBranch: "origin/feature/x", + setUpstream: true, + }); + assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "feature/x"]), "Add feature"); + assert.equal(yield* git(remote, ["rev-parse", "dev"]), devSha); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/x", + ); + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + }), + ); + + it.effect("keeps a recorded merge base when publishing a tracked branch", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]); + yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/y", + upstreamBranch: "origin/feature/y", + setUpstream: true, + }); + assert.equal( + yield* driver.readConfigValue(cwd, "branch.feature/y.gh-merge-base"), + "release/v2", + ); + }), + ); + + it.effect("still pushes a git-mangled tracking alias to its upstream head", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "my-org/upstream", remote]); + yield* git(cwd, ["push", "my-org/upstream", "main:effect-atom"]); + yield* git(cwd, ["fetch", "my-org/upstream"]); + // `checkout --track my-org/upstream/effect-atom` cannot name the local + // branch `effect-atom`, so git keeps `upstream/effect-atom`. Its + // upstream is still its published head. + yield* git(cwd, ["checkout", "--track", "my-org/upstream/effect-atom"]); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), + "upstream/effect-atom", + ); + yield* writeTextFile(cwd, "alias.txt", "alias\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add alias update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "upstream/effect-atom", + upstreamBranch: "my-org/upstream/effect-atom", + setUpstream: false, + }); + assert.equal( + yield* git(remote, ["log", "-1", "--pretty=%s", "effect-atom"]), + "Add alias update", + ); + }), + ); + it.effect("pushes to the requested remote instead of the primary remote", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3d0f66c347f4..71e478cbaa3d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -39,6 +39,10 @@ import { import { ServerConfig } from "../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +// `git worktree add` checks out the full tree, so on large repositories it can +// take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle +// machine). Give it generous headroom while still bounding a genuinely hung git. +const WORKTREE_ADD_TIMEOUT_MS = 300_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; @@ -428,6 +432,17 @@ function isUnbornHeadStderr(stderr: string): boolean { ); } +// Matches `git worktree remove` on a path git no longer tracks: "is not a +// working tree" when the registration is gone, "cannot remove working tree" +// when older gits fail validation on a registered-but-deleted directory. +function isMissingWorktreeStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); + return ( + normalized.includes("is not a working tree") || + normalized.includes("cannot remove working tree") + ); +} + interface Trace2Monitor { readonly env: NodeJS.ProcessEnv; readonly flush: Effect.Effect; @@ -1992,6 +2007,55 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { + // A branch tracking a differently named ref was cut from it, the way + // `git checkout -b feature origin/dev` and our own worktree flow leave + // it. That upstream is the branch's base, not its publish target, and + // pushing HEAD onto it would write feature commits to a shared branch + // (bare `git push` refuses this under push.default=simple). The one + // same-repo tracking setup that legitimately differs is a git-mangled + // alias such as local `upstream/effect-atom` for my-org/upstream's + // `effect-atom`: the branch name ends in the upstream head while the + // upstream ref ends in the branch name. + const isAliasOfUpstreamHead = + branch === currentUpstream.branchName || + (branch.endsWith(`/${currentUpstream.branchName}`) && + currentUpstream.upstreamRef.endsWith(`/${branch}`)); + if (!isAliasOfUpstreamHead) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + const remoteName = publishRemoteName ?? currentUpstream.remoteName; + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + // `-u` retargets the upstream to the published branch, so keep the + // base recorded first; base resolution reads gh-merge-base before the + // upstream ref. + const configuredMergeBase = yield* runGitStdout( + "GitVcsDriver.pushCurrentBranch.readMergeBase", + cwd, + ["config", "--get", `branch.${branch}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (configuredMergeBase.length === 0) { + yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ + "config", + `branch.${branch}.gh-merge-base`, + currentUpstream.branchName, + ]); + } + yield* runGit( + "GitVcsDriver.pushCurrentBranch.pushOwnBranch", + cwd, + ["push", "-u", remoteName, `HEAD:refs/heads/${publishBranch}`], + { timeoutMs: null }, + ); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${remoteName}/${publishBranch}`, + setUpstream: true, + }; + } + yield* runGit( "GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, @@ -2769,8 +2833,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, }); + // `git worktree add` leaves submodules empty, so a repo that keeps agent + // skills, tooling or source in one gets a worktree that is quietly missing + // them. Best-effort: the objects are usually already in the parent's + // `.git/modules`, but a first-ever clone needs the network, and failing to + // populate a submodule must not roll back the caller's thread. + const hasSubmodules = yield* fileSystem + .exists(path.join(worktreePath, ".gitmodules")) + .pipe(Effect.orElseSucceed(() => false)); + if (hasSubmodules) { + yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [ + "submodule", + "update", + "--init", + "--recursive", + ]).pipe( + Effect.catch((cause) => + Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { + worktreePath, + cause, + }), + ), + ); + } + if (input.newRefName && input.baseRefName) { const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); const parsedBaseRef = parseRemoteRefWithRemoteNames( @@ -2982,9 +3071,47 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* args.push("--force"); } args.push(input.path); - yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { + const result = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.removeWorktree", + input.cwd, + args, + { timeoutMs: 15_000, allowNonZeroExit: true }, + ); + if (result.exitCode === 0) { + return; + } + // Threads can share a worktree path, and worktrees get removed or pruned + // outside the app, so a worktree that is already gone is a no-op rather + // than an error. Prune so no stale registration lingers to block a later + // `worktree add` at the same path. + const alreadyGone = + isMissingWorktreeStderr(result.stderr) && + !(yield* fileSystem.exists(input.path).pipe(Effect.orElseSucceed(() => false))); + if (alreadyGone) { + yield* pruneWorktrees({ cwd: input.cwd }); + return; + } + // Raw stderr stays out of both the wire error and the log (it can carry + // secrets); log bounded diagnostics so a genuine failure is visible + // server-side. + yield* Effect.logWarning( + `GitVcsDriver.removeWorktree: git worktree remove exited with code ${result.exitCode} for ${input.path} (stderr length ${result.stderr.length}).`, + ); + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.removeWorktree", cwd: input.cwd, args }), + detail: "git worktree remove failed", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + }); + + const pruneWorktrees: GitVcsDriver.GitVcsDriver["Service"]["pruneWorktrees"] = Effect.fn( + "pruneWorktrees", + )(function* (input) { + yield* executeGit("GitVcsDriver.pruneWorktrees", input.cwd, ["worktree", "prune"], { timeoutMs: 15_000, - fallbackErrorDetail: "git worktree remove failed", + fallbackErrorDetail: "git worktree prune failed", }); }); @@ -3184,6 +3311,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, + resolveDefaultBranchName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), remoteExists, resolveRemoteTrackingCommit, @@ -3192,6 +3320,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), + pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ad17b75cc4ee..44bd29660aa0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -15,9 +15,13 @@ import { type AuthAccessStreamEvent, type AuthEnvironmentScope, AuthSessionId, + ClientSurface, CommandId, type DiscoveredLocalServerList, EventId, + type EditorId, + type FileManagerRevealKind, + type OrchestrationClientOrigin, type OrchestrationCommand, type GitActionProgressEvent, type GitManagerServiceError, @@ -40,6 +44,7 @@ import { ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, + ProviderUploadFeedbackError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, type ServerSelfUpdateError, @@ -70,7 +75,10 @@ import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; -import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; +import { + cleanupFailedUploadedAttachments, + normalizeDispatchCommand, +} from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -79,6 +87,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -88,6 +97,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; +import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -106,6 +116,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -127,16 +138,25 @@ import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5); +const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); -export const resolveAvailableEditorsForConfig = ( - discovery: Effect.Effect, E, R>, +const resolveDiscoveryForConfig = ( + discovery: Effect.Effect, + onTimeout: () => A, ) => discovery.pipe( - Effect.timeoutOption(EDITOR_DISCOVERY_TIMEOUT), - Effect.map(Option.getOrElse(() => [])), + Effect.timeoutOption(CONFIG_DISCOVERY_TIMEOUT), + Effect.map(Option.getOrElse(onTimeout)), ); +export const resolveAvailableEditorsForConfig = ( + discovery: Effect.Effect, E, R>, +) => resolveDiscoveryForConfig(discovery, () => []); + +export const resolveFileManagerRevealKindForConfig = ( + discovery: Effect.Effect, +) => resolveDiscoveryForConfig(discovery, () => undefined); + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -349,8 +369,60 @@ function toAuthAccessStreamEvent( } } +const isClientSurface = Schema.is(ClientSurface); +const MAX_CLIENT_APP_VERSION_LENGTH = 64; +const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; + +// Optional client identity announced on the /ws upgrade URL next to wsTicket. +// Lenient by design: absent or malformed values degrade to {} so a connection +// never fails over attribution metadata. +function readClientConnectionOrigin( + request: HttpServerRequest.HttpServerRequest, +): OrchestrationClientOrigin { + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return {}; + } + const surface = url.value.searchParams.get("clientSurface"); + const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; + return { + ...(isClientSurface(surface) ? { surface } : {}), + ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH + ? { appVersion } + : {}), + }; +} + +const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ + ...(origin.surface !== undefined ? { surface: origin.surface } : {}), + ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), +}); + +function readMobileDeviceAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url) || url.value.searchParams.get("clientSurface") !== "mobile") { + return {}; + } + + const os = url.value.searchParams.get("clientOs"); + const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; + const osMajorVersion = Number(rawOsMajorVersion); + const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; + + return { + ...(os === "iOS" || os === "Android" ? { os } : {}), + ...(rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0 + ? { osMajorVersion } + : {}), + ...(deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH + ? { deviceModel } + : {}), + }; +} + const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, + clientOrigin: OrchestrationClientOrigin, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], ) => WsRpcGroup.toLayer( @@ -359,6 +431,35 @@ const makeWsRpcLayer = ( const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const analytics = yield* AnalyticsService.AnalyticsService; + // Every command dispatched on this connection carries the connecting + // client's origin, including server-generated bootstrap sub-commands: + // the client's request caused them. + const hasClientOrigin = + clientOrigin.surface !== undefined || clientOrigin.appVersion !== undefined; + const dispatchFromClient: OrchestrationEngine.OrchestrationEngineShape["dispatch"] = ( + command, + ) => + orchestrationEngine.dispatch( + command, + hasClientOrigin ? { origin: clientOrigin } : undefined, + ); + const originProps = clientOriginAnalyticsProps(clientOrigin); + const recordClientCommandAnalytics = (command: OrchestrationCommand) => { + switch (command.type) { + case "thread.create": + return analytics.record("client.thread.started", originProps); + case "thread.turn.start": + return command.bootstrap?.createThread + ? Effect.andThen( + analytics.record("client.thread.started", originProps), + analytics.record("client.turn.requested", originProps), + ) + : analytics.record("client.turn.requested", originProps); + default: + return Effect.void; + } + }; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; @@ -371,6 +472,7 @@ const makeWsRpcLayer = ( const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const providerService = yield* ProviderService.ProviderService; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; @@ -514,7 +616,7 @@ const makeWsRpcLayer = ( activityId: serverEventId, }).pipe( Effect.flatMap(({ commandId, activityId }) => - orchestrationEngine.dispatch({ + dispatchFromClient({ type: "thread.activity.append", commandId, threadId: input.threadId, @@ -769,15 +871,15 @@ const makeWsRpcLayer = ( createdThread ? serverCommandId("bootstrap-thread-delete").pipe( Effect.flatMap((commandId) => - orchestrationEngine.dispatch({ + dispatchFromClient({ type: "thread.delete", commandId, threadId: command.threadId, }), ), - Effect.ignoreCause({ log: true }), + Effect.as(true), ) - : Effect.void; + : Effect.succeed(false); const recordSetupScriptLaunchFailure = (input: { readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError; @@ -896,7 +998,7 @@ const makeWsRpcLayer = ( const bootstrapProgram = Effect.gen(function* () { if (bootstrap?.createThread) { - yield* orchestrationEngine.dispatch({ + yield* dispatchFromClient({ type: "thread.create", commandId: yield* serverCommandId("bootstrap-thread-create"), threadId: command.threadId, @@ -944,7 +1046,7 @@ const makeWsRpcLayer = ( }); targetWorktreePath = worktree.worktree.path; const metadataCommandId = yield* serverCommandId("bootstrap-thread-meta-update"); - yield* orchestrationEngine.dispatch( + yield* dispatchFromClient( createdThread ? { type: "thread.managed-worktree.record", @@ -970,7 +1072,7 @@ const makeWsRpcLayer = ( yield* runSetupProgram(); - return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + return yield* dispatchFromClient(finalTurnStartCommand); }); return yield* bootstrapProgram.pipe( @@ -979,7 +1081,27 @@ const makeWsRpcLayer = ( if (Cause.hasInterruptsOnly(cause)) { return Effect.fail(dispatchError); } - return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); + return Effect.uninterruptible(cleanupCreatedThread()).pipe( + Effect.matchCauseEffect({ + onFailure: (cleanupCause) => + Effect.logWarning("bootstrap thread cleanup failed", { + threadId: command.threadId, + detail: Cause.pretty(cleanupCause), + }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), + onSuccess: (threadDeleted) => + Effect.fail( + threadDeleted + ? new OrchestrationDispatchCommandError({ + message: dispatchError.message, + ...(dispatchError.cause !== undefined + ? { cause: dispatchError.cause } + : {}), + bootstrapThreadDisposition: "deleted", + }) + : dispatchError, + ), + }), + ); }), ); }); @@ -990,13 +1112,11 @@ const makeWsRpcLayer = ( const dispatchEffect = normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap ? dispatchBootstrapTurnStart(normalizedCommand) - : orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); + : dispatchFromClient(normalizedCommand).pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); return startup .enqueueCommand(dispatchEffect) @@ -1015,6 +1135,14 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ) + : undefined; return { environment, @@ -1024,9 +1152,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ), + availableEditors, // Same discovery-with-timeout treatment as editors: a slow probe // must not stall server.getConfig, so it degrades to no targets. remoteOpenTargets: yield* resolveAvailableEditorsForConfig( @@ -1044,6 +1170,12 @@ const makeWsRpcLayer = ( }, settings, shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), threadResumeCompletionMarker: true, threadSnapshotPagination: true, }; @@ -1092,7 +1224,10 @@ const makeWsRpcLayer = ( ), ) : false; - const result = yield* dispatchNormalizedCommand(normalizedCommand); + const result = yield* dispatchNormalizedCommand(normalizedCommand).pipe( + Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), + ); + yield* recordClientCommandAnalytics(normalizedCommand); if (parkingCommand) { const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; if (shouldStopSessionAfterCommand) { @@ -1469,6 +1604,20 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.providerUploadFeedback]: (input) => + observeRpcEffect( + WS_METHODS.providerUploadFeedback, + providerService.uploadFeedback(input).pipe( + Effect.mapError( + (cause) => + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause, + }), + ), + ), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, @@ -1869,6 +2018,16 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.attachmentsCreateUploadUrl]: (input) => + observeRpcEffect(WS_METHODS.attachmentsCreateUploadUrl, issueAttachmentUploadUrl(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.attachmentsDelete]: (input) => + observeRpcEffect( + WS_METHODS.attachmentsDelete, + deletePendingAttachment(input.attachmentId), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -2325,6 +2484,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const analytics = yield* AnalyticsService.AnalyticsService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), @@ -2333,11 +2493,17 @@ export const websocketRpcRouteLayer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); + const clientOrigin = readClientConnectionOrigin(request); + yield* sessions.recordClientConnection(session.sessionId, clientOrigin); + yield* analytics.record("client.connected", { + ...clientOriginAnalyticsProps(clientOrigin), + ...readMobileDeviceAnalyticsProps(request), + }); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker).pipe( + makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 103fa16ec376..47032f6c6809 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -365,6 +365,40 @@ describe("projectActivityPayload", () => { } }); + it("preserves failed stored tool outcomes for web and mobile clients", () => { + const activities = [ + makeActivity("failed-command", "command_execution", { + item: { + command: "vp test run", + exitCode: 1, + status: "failed", + }, + }), + makeActivity("failed-mcp", "mcp_tool_call", { + item: { + server: "simulator", + tool: "build", + arguments: {}, + status: "failed", + }, + }), + ]; + + for (const activity of activities) { + const projected = projectActivityPayload(activity); + expect(projected.payload).toMatchObject({ status: "failed" }); + + const [webEntry] = deriveWorkLogEntries([projected]); + expect(webEntry?.toolLifecycleStatus).toBe("failed"); + + const [mobileGroup] = buildThreadFeed(makeThread([projected])); + expect(mobileGroup).toMatchObject({ type: "activity-group" }); + if (mobileGroup?.type === "activity-group") { + expect(mobileGroup.activities[0]?.status).toBe("failure"); + } + } + }); + it("projects snapshot and event transports without mutating their sources", () => { const activity = fixtures[0]!; const thread = makeThread([activity]); @@ -532,31 +566,6 @@ describe("superseded tool.updated snapshot dedup", () => { expect(projectedIds([anonymous, completed])).toEqual([anonymous.id, completed.id]); }); - it("does not filter live activity-appended events", () => { - const update = makeToolLifecycleActivity("upd-live-event", "tool.updated"); - const event = { - sequence: 11, - eventId: EventId.make("event-tool-updated"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-projection"), - occurredAt: "2026-07-27T00:00:03.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.activity-appended", - payload: { - threadId: ThreadId.make("thread-projection"), - activity: update, - }, - } satisfies Extract; - - const projected = projectActivityEvent(event); - expect( - projected.type === "thread.activity-appended" ? projected.payload.activity.id : undefined, - ).toEqual(update.id); - }); - it("leaves the collapsed work log identical to the full history", () => { const activities = [ makeToolLifecycleActivity("upd-1", "tool.updated", { detail: "writing" }), @@ -677,29 +686,4 @@ describe("context-window snapshot dedup", () => { }); expect(projected.thread.activities).toEqual([projectActivityPayload(fixtures[4]!)]); }); - - it("does not filter live activity-appended events", () => { - const activity = makeContextWindowActivity("ctx-live", 4_000); - const event = { - sequence: 9, - eventId: EventId.make("event-ctx"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-projection"), - occurredAt: "2026-07-27T00:00:02.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.activity-appended", - payload: { - threadId: ThreadId.make("thread-projection"), - activity, - }, - } satisfies Extract; - - const projected = projectActivityEvent(event); - expect( - projected.type === "thread.activity-appended" ? projected.payload.activity : undefined, - ).toEqual(activity); - }); }); diff --git a/apps/web/package.json b/apps/web/package.json index 598feaec0ce9..b73eefc8d539 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.33", + "version": "0.0.34", "private": true, "type": "module", "scripts": { @@ -34,6 +34,7 @@ "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "effect": "catalog:", + "heic-to": "^1.5.2", "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts new file mode 100644 index 000000000000..3e6c1fad0448 --- /dev/null +++ b/apps/web/src/appearanceContrast.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { applyAppearanceContrast } from "./appearanceContrast"; + +function makeRoot() { + const setProperty = vi.fn(); + return { + root: { style: { setProperty } } as unknown as HTMLElement, + setProperty, + }; +} + +describe("applyAppearanceContrast", () => { + it("boosts semantic contrast above the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 135); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%"); + }); + + it("supports the maximum contrast boost", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 200); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%"); + }); + + it("softens semantic contrast below the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 70); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); + }); + + it("disables contrast mixing at the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 100); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); + }); +}); diff --git a/apps/web/src/appearanceContrast.ts b/apps/web/src/appearanceContrast.ts new file mode 100644 index 000000000000..a26dca0131e6 --- /dev/null +++ b/apps/web/src/appearanceContrast.ts @@ -0,0 +1,10 @@ +import type { AppearanceContrast } from "@t3tools/contracts/settings"; + +export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void { + root.style.setProperty("--appearance-contrast-base", `${Math.min(contrast, 100)}%`); + root.style.setProperty("--appearance-contrast-boost", `${Math.max(contrast - 100, 0)}%`); + root.style.setProperty( + "--appearance-contrast-border-boost", + `${Math.max(contrast - 100, 0) / 4}%`, + ); +} diff --git a/apps/web/src/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts index e12c667d23d7..cb3382449598 100644 --- a/apps/web/src/browser/annotationTheme.ts +++ b/apps/web/src/browser/annotationTheme.ts @@ -10,17 +10,17 @@ export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { colorScheme: root.classList.contains("dark") ? "dark" : "light", radius: readVariable(styles, "--radius", "0.625rem"), background: readVariable(styles, "--background", "white"), - foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"), + foreground: readVariable(styles, "--contrast-foreground", "oklch(0.269 0 0)"), popover: readVariable(styles, "--popover", "white"), - popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"), + popoverForeground: readVariable(styles, "--contrast-popover-foreground", "oklch(0.269 0 0)"), primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"), primaryForeground: readVariable(styles, "--primary-foreground", "white"), muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"), - mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"), + mutedForeground: readVariable(styles, "--contrast-muted-foreground", "oklch(0.556 0 0)"), accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"), - accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"), - border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"), - input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"), + accentForeground: readVariable(styles, "--contrast-accent-foreground", "oklch(0.269 0 0)"), + border: readVariable(styles, "--contrast-border", "rgb(0 0 0 / 8%)"), + input: readVariable(styles, "--contrast-input", "rgb(0 0 0 / 10%)"), ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"), fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"), fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 9499ee5a6915..2ca9ad4ae311 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,151 @@ -import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { orderedListGutterStyle } from "./ChatMarkdown"; +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../state/session", async (importOriginal) => ({ + ...(await importOriginal()), + usePreparedConnection: () => ({ _tag: "Loading" }), +})); +vi.mock("../state/entities", () => ({ + readThreadShell: () => null, + useProjects: () => [], +})); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => undefined, + matchesLinkedPullRequestUrl: () => false, + parseChangeRequestUrl: () => null, + useOpenChangeRequestLink: () => vi.fn(), +})); + +import ChatMarkdown, { + canUseMarkdownFileShellActions, + hasMarkdownFilePrimaryAction, + orderedListGutterStyle, + shouldUseMarkdownFileBrowserPrimaryAction, +} from "./ChatMarkdown"; + +describe("canUseMarkdownFileShellActions", () => { + const environmentId = EnvironmentId.make("environment-1"); + + it("allows editor and file manager actions for local environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); + }); + + it("hides shell actions until the environment mode is resolved", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); + }); + + it("hides editor and file manager actions for remote environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); + expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); + }); + + it("hides shell actions when no environment owns the markdown", () => { + expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); + }); +}); + +describe("hasMarkdownFilePrimaryAction", () => { + it("keeps the chip interactive when an editor, browser, or panel can open it", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: true, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: true, + }), + ).toBe(true); + }); + + it("removes the link affordance when no primary action can open the file", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(false); + }); +}); + +describe("ChatMarkdown file option chips", () => { + it("keeps the fallback button text selectable", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(" { + it("uses the browser when it is the only available primary action", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + }); + + it("preserves the normal editor and panel defaults for HTML files", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(false); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(false); + }); + + it("continues to open PDF files in the browser by default", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.pdf", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(true); + }); +}); describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -24,13 +169,123 @@ describe("orderedListGutterStyle", () => { it("accounts for a non-default start attribute", () => { // start=95 + 9 items => last marker is "103", three digits. expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); + expect(orderedListGutterStyle(5, "999995")).toEqual({ "--list-gutter": "7ch" }); }); it("scales further for four-digit markers", () => { expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); }); + it("uses the widest marker and includes a negative start's minus sign", () => { + expect(orderedListGutterStyle(1001, -1000)).toEqual({ "--list-gutter": "6ch" }); + expect(orderedListGutterStyle(3, -15)).toEqual({ "--list-gutter": "4ch" }); + expect(orderedListGutterStyle(3, -5)).toBeUndefined(); + }); + it("treats a missing/zero item count as a single item", () => { expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); + expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" }); + }); +}); + +describe("ChatMarkdown Windows file links", () => { + const environmentId = EnvironmentId.make("env-windows"); + + it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); + expect(html).toContain("chat-markdown-file-link"); + }); + + it.each([true, false])("normalizes backslashes with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); + expect(html).toContain("chat-markdown-file-link"); + }); + + it.each([true, false])( + "distinguishes same-named backslash paths with parseRawHtml=%s", + (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("index.ts · project/src"); + expect(html).toContain("index.ts · project/test"); + }, + ); + + it.each([true, false])( + "does not disambiguate the same file in links and inline code with parseRawHtml=%s", + (parseRawHtml) => { + const path = String.raw`C:\Users\shawn\project\src\main.ts`; + const html = renderToStaticMarkup( + , + ); + + expect(html.match(/chat-markdown-file-link/g)).toHaveLength(2); + expect(html).not.toContain("main.ts ·"); + }, + ); + + it.each([true, false])("preserves reference links with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); + expect(html).toContain("chat-markdown-file-link"); + }); + + it.each([true, false])("still rejects unsafe schemes with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("javascript:"); + expect(html).not.toContain("d:alert"); + expect(html).not.toContain("chat-markdown-file-link"); }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c5cf8d16189f..db7d42564a0e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -13,12 +13,18 @@ import { TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; -import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; +import type { + EnvironmentId, + ScopedThreadRef, + ServerProviderSkill, + ThreadLinkedPullRequest, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { @@ -47,6 +53,10 @@ import { remarkGithubAlerts } from "../markdown-github-alerts"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { + revealInFileExplorerLabelForKind, + revealInFileExplorerLabelForOs, +} from "./preview/fileExplorerLabel"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, @@ -60,7 +70,12 @@ import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; -import { useOpenInPreferredEditor } from "../editorPreferences"; +import { + PreferredEditorEnvironmentRequiredError, + useOpenInPreferredEditor, + usePreferredEditor, +} from "../editorPreferences"; +import { openInEditorMenuLabel } from "../editorLabels"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; @@ -75,32 +90,45 @@ import { } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { + extractMarkdownLinkHrefs, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, + shouldOpenMarkdownFileLinkInBrowserByDefault, + shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; +import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; -import { useActiveEnvironmentId } from "../state/entities"; +import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; +import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; +import { threadEnvironment } from "../state/threads"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; -import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; +import { + findProjectForChangeRequest, + matchesLinkedPullRequestUrl, + parseChangeRequestUrl, + useOpenChangeRequestLink, +} from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -112,6 +140,8 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; + /** Environment that owns non-thread markdown, such as a pull request panel. */ + environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; @@ -122,9 +152,39 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; } +export function canUseMarkdownFileShellActions( + environmentId: EnvironmentId | null, + remoteOpenMode: RemoteOpenMode, + isRemoteOpenResolved: boolean, +): boolean { + return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; +} + +export function hasMarkdownFilePrimaryAction(input: { + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; +}): boolean { + return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; +} + +export function shouldUseMarkdownFileBrowserPrimaryAction(input: { + iconPath: string; + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; +}): boolean { + return ( + input.canOpenInBrowser && + (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || + (!input.canOpenInEditor && !input.canOpenInPanel)) + ); +} + const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; @@ -161,22 +221,54 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb } /** - * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits two-digit - * decimal markers. Once a list's last item reaches three digits (item 100+), - * `list-style-position: outside` paints the marker wider than that gutter and - * the leading digit gets clipped by the item's own overflow. Rather than - * widening the gutter for every list, only lists whose last marker is 3+ - * digits get a wider `--list-gutter`, sized to that marker's digit count. + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits markers up to + * two characters wide. Once a marker reaches three characters (item 100+), + * `list-style-position: outside` paints it wider than that gutter and clips + * the leading character against the item's own overflow. Rather than widening + * the gutter for every list, only lists whose widest marker is 3+ characters + * get a wider `--list-gutter`. The width includes a negative marker's minus + * sign. */ export function orderedListGutterStyle( itemCount: number, - start: number | undefined, + start: unknown, ): { "--list-gutter": string } | undefined { - const firstNumber = typeof start === "number" && Number.isFinite(start) ? start : 1; + const parsedStart = Number.parseInt(String(start ?? 1), 10); + const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); - const digits = String(Math.abs(lastNumber)).length; - if (digits <= 2) return undefined; - return { "--list-gutter": `${digits + 1}ch` }; + const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); + if (markerWidth <= 2) return undefined; + return { "--list-gutter": `${markerWidth + 1}ch` }; +} + +type MarkdownHtmlAstNode = { + type?: string; + tagName?: string; + properties?: Record; + children?: MarkdownHtmlAstNode[]; +}; + +/** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */ +function rehypeNormalizeWindowsImageSrc() { + return (tree: MarkdownHtmlAstNode) => { + const visit = (node: MarkdownHtmlAstNode) => { + const src = node.properties?.src; + if ( + node.type === "element" && + node.tagName === "img" && + typeof src === "string" && + WINDOWS_DRIVE_PATH_REGEX.test(src) + ) { + node.properties = { + ...node.properties, + src: `file:///${src.replaceAll("\\", "/")}`, + }; + } + node.children?.forEach(visit); + }; + + visit(tree); + }; } const CHAT_MARKDOWN_SANITIZE_SCHEMA = { @@ -190,6 +282,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], + src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters[0]; @@ -198,7 +291,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, - remarkTagInlineCode, + remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -207,11 +300,12 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, - remarkTagInlineCode, + remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, + rehypeNormalizeWindowsImageSrc, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; @@ -292,6 +386,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { type MarkdownAstNode = { type?: string; meta?: unknown; + url?: string; data?: { hProperties?: Record; }; @@ -318,15 +413,20 @@ function remarkPreserveCodeMeta() { } /** - * Fenced code also lands on the `code` component, and inline vs block is no - * longer distinguishable there once both render `` — so inline spans are - * tagged on the mdast, where the distinction still exists. Code inside a link - * label stays untagged: linkifying it would nest an anchor inside the link's - * anchor and steal its clicks. + * Preserve Windows drive links as allowed `file:` URLs before sanitization. + * The same traversal tags inline code while it can still be distinguished + * from fenced code. Code inside links stays untagged to avoid nested anchors. */ -function remarkTagInlineCode() { +function remarkNormalizeLinksAndTagInlineCode() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode, insideLink: boolean) => { + if ( + (node.type === "link" || node.type === "definition") && + typeof node.url === "string" && + WINDOWS_DRIVE_PATH_REGEX.test(node.url) + ) { + node.url = `file:///${node.url.replaceAll("\\", "/")}`; + } if (node.type === "inlineCode" && !insideLink) { node.data = { ...node.data, @@ -780,15 +880,19 @@ interface MarkdownFileLinkProps { copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; - onOpen: (targetPath: string) => Promise>; + onOpen?: ((targetPath: string) => Promise>) | undefined; onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; + openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; + onReveal?: (() => Promise>) | undefined; + /** Platform-specific menu label ("Reveal in Finder", ...); required for the + reveal item to show. */ + revealLabel?: string | undefined; className?: string | undefined; } -const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; -const MARKDOWN_FILE_LINK_CLASS_NAME = - "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; +const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link"; +const MARKDOWN_FILE_LINK_CLASS_NAME = `${MARKDOWN_FILE_CHIP_CLASS_NAME} cursor-pointer transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70`; function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); @@ -799,14 +903,12 @@ function pathParentSegments(path: string): string[] { function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { const groups = new Map>(); for (const filePath of filePaths) { - const pathSegments = filePath - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0); + const normalizedPath = filePath.replaceAll("\\", "/"); + const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); const basename = pathSegments[pathSegments.length - 1]; if (!basename) continue; const group = groups.get(basename) ?? new Set(); - group.add(filePath); + group.add(normalizedPath); groups.set(basename, group); } @@ -865,19 +967,12 @@ function extractInlineCodeSpans(text: string): string[] { return spans; } -function extractMarkdownLinkHrefs(text: string): string[] { - const hrefs: string[] = []; - for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { - const href = match[1]?.trim(); - if (!href) continue; - hrefs.push(href); - } - return hrefs; -} - function normalizeMarkdownLinkHrefKey(href: string): string { const normalizedHref = normalizeMarkdownLinkDestination(href); - return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) + ? rewrittenHref.replaceAll("\\", "/") + : rewrittenHref; } const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; @@ -911,6 +1006,62 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: ); }); +const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = + "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain"; + +// block! outranks the unlayered `.chat-markdown img { display: inline-block }` +// rule, keeping workspace images on the same block layout as their placeholder. +const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( + CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, + "my-1 block! rounded-lg border border-border/40", +); + +function ChatMarkdownImageFallback(props: { readonly alt: string }) { + return ( + + + {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + + ); +} + +/** Markdown images whose src is a workspace file path load through a signed asset URL. */ +const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: { + readonly threadRef: ScopedThreadRef; + readonly path: string; + readonly alt: string; +}) { + const assetUrl = useAssetUrlState(props.threadRef.environmentId, { + _tag: "workspace-file", + threadId: props.threadRef.threadId, + path: props.path, + }); + const [failedUrl, setFailedUrl] = useState(null); + + if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + return ; + } + if (assetUrl._tag !== "Success") { + return ( + + ); + } + return ( + {props.alt} setFailedUrl(assetUrl.url)} + /> + ); +}); + function leadingExternalLinkTextLength(text: string): number { const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; if (protocol) return protocol.length; @@ -1088,10 +1239,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ threadRef, onOpen, onOpenInPanel, + openInEditorMenuLabel, onOpenInBrowser, + onReveal, + revealLabel, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { + if (!onOpen) { + return; + } void (async () => { try { const result = await onOpen(targetPath); @@ -1172,6 +1329,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }, [onOpenInBrowser, targetPath]); + const handleRevealInFileManager = useCallback(() => { + if (!onReveal) { + return; + } + void (async () => { + try { + const result = await onReveal(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + })(); + }, [onReveal, targetPath]); + const handleCopy = useCallback( (value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -1211,25 +1406,23 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ [targetPath], ); - const handleContextMenu = useCallback( - async (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - + const showFileContextMenu = useCallback( + async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; try { const clicked = await api.contextMenu.show( [ - { id: "open", label: "Open in editor" }, + ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), + ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, - { x: event.clientX, y: event.clientY }, + position, ); if (clicked === "open") { @@ -1240,6 +1433,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } + if (clicked === "reveal") { + handleRevealInFileManager(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1254,38 +1451,110 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + handleRevealInFileManager, + onOpenInBrowser, + onOpen, + onReveal, + openInEditorMenuLabel, + revealLabel, + targetPath, + ], + ); + + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const position = + event.clientX === 0 && event.clientY === 0 + ? (() => { + const bounds = event.currentTarget.getBoundingClientRect(); + return { x: bounds.left, y: bounds.bottom }; + })() + : { x: event.clientX, y: event.clientY }; + void showFileContextMenu(position); + }, + [showFileContextMenu], ); + const canOpenInEditor = onOpen !== undefined; + const canOpenInBrowser = onOpenInBrowser !== undefined; + const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath); + const hasPrimaryAction = hasMarkdownFilePrimaryAction({ + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + }); + const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath, + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + }); + return ( { - event.preventDefault(); - event.stopPropagation(); - if (onOpenInBrowser) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - + hasPrimaryAction ? ( + { + event.preventDefault(); + event.stopPropagation(); + if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { + handleOpenInEditor(); + return; + } + if (useBrowserPrimaryAction) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); + }} + onContextMenu={handleContextMenu} + > + + + ) : ( + + ) } /> -
- {displayPath} + {/* The full path: the chip already shows the shortened form, and a link + to the workspace root collapses to a bare label that repeats it. */} +
+ {targetPath}
@@ -1309,7 +1578,10 @@ function areMarkdownFileLinkPropsEqual( previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && previous.onOpenInPanel === next.onOpenInPanel && + previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && + previous.onReveal === next.onReveal && + previous.revealLabel === next.revealLabel && previous.className === next.className ); } @@ -1318,6 +1590,7 @@ function ChatMarkdown({ text, cwd, threadRef, + environmentId: explicitEnvironmentId, onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, @@ -1335,12 +1608,52 @@ function ChatMarkdown({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); - const environmentId = useActiveEnvironmentId(); - const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const openInPreferredEditor = useOpenInPreferredEditor( + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; + const remoteOpen = useRemoteOpenResolution(environmentId); + const canUseShellActions = canUseMarkdownFileShellActions( environmentId, - serverConfig?.availableEditors ?? [], + remoteOpen.state.mode, + remoteOpen.isResolved, + ); + const preparedConnection = usePreparedConnection(environmentId); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const threadServerConfig = useAtomValue( + serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), + ); + const projects = useProjects(); + const availableEditors = serverConfig?.availableEditors ?? []; + const [preferredEditor] = usePreferredEditor(availableEditors); + const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); + const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); + const revealInFileManagerLabel = + environmentId !== null && + serverConfig?.shellRevealInFileManager === true && + serverConfig.availableEditors.includes("file-manager") + ? serverConfig.shellRevealInFileManagerKind === undefined + ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) + : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) + : undefined; + const revealFileInFileManager = useCallback( + (filePath: string) => { + if (environmentId === null) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })), + ), + ); + } + return openInEditor({ + environmentId, + input: { cwd: filePath, editor: "file-manager", reveal: true }, + }); + }, + [environmentId, openInEditor], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { @@ -1391,6 +1704,54 @@ function ChatMarkdown({ event.clipboardData.setData("text/html", payload.html); }, []); const openChangeRequestLink = useOpenChangeRequestLink(threadRef); + const resolveThreadPullRequest = useCallback( + (href: string): ThreadLinkedPullRequest | null => { + if ( + threadRef === undefined || + readThreadShell(threadRef) === null || + threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true + ) { + return null; + } + const parsed = parseChangeRequestUrl(href); + if (parsed === null) return null; + const project = findProjectForChangeRequest( + projects.filter((candidate) => candidate.environmentId === threadRef.environmentId), + parsed, + ); + if (project === undefined) return null; + return { + projectId: project.id, + repository: project.repositoryIdentity?.displayName ?? parsed.repository, + number: parsed.number, + url: href, + }; + }, + [projects, threadRef, threadServerConfig], + ); + const updateThreadPullRequestLink = useCallback( + async (href: string, linked: boolean) => { + if (threadRef === undefined) return; + const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null; + if (linked && linkedPullRequest === null) { + throw new Error("The pull request is not available in this environment."); + } + if (!linked) { + const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest; + if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) { + return; + } + } + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, linkedPullRequest }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + throw squashAtomCommandFailure(result); + } + }, + [resolveThreadPullRequest, threadRef, updateThreadMetadata], + ); const openExternalLinkInPreview = useCallback( (url: string) => { if (!threadRef) { @@ -1434,6 +1795,26 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + const findWorkspaceBasenameMatch = useCallback( + async (workspaceRelativePath: string) => { + if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + return null; + } + const result = await searchProjectEntries({ + environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + return result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + }, + [cwd, environmentId, searchProjectEntries], + ); // A bare filename resolves to the workspace root, which is rarely where the // file is, so ask the index before opening. const openFileInPanel = useCallback( @@ -1449,24 +1830,23 @@ function ChatMarkdown({ return; } void (async () => { - const result = await searchProjectEntries({ - environmentId: threadRef.environmentId, - input: { - cwd, - query: workspaceRelativePath, - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - kind: "file", - }, - }); - const match = - result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; + const match = await findWorkspaceBasenameMatch(workspaceRelativePath); if (!isLatestLookup()) return; openAt(match ?? workspaceRelativePath); })(); }, - [cwd, searchProjectEntries, threadRef], + [cwd, findWorkspaceBasenameMatch, threadRef], + ); + const revealMarkdownFileInFileManager = useCallback( + async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; + const match = workspaceRelativePath + ? await findWorkspaceBasenameMatch(workspaceRelativePath) + : null; + const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; + return revealFileInFileManager(filePath); + }, + [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that @@ -1477,7 +1857,9 @@ function ChatMarkdown({ copyMarkdown: string, className?: string, ) => { - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const parentSuffix = fileLinkParentSuffixByPath.get( + fileLinkMeta.filePath.replaceAll("\\", "/"), + ); const labelParts = [fileLinkMeta.basename]; if (typeof parentSuffix === "string" && parentSuffix.length > 0) { labelParts.push(parentSuffix); @@ -1500,8 +1882,15 @@ function ChatMarkdown({ copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} - onOpen={openInPreferredEditor} + {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} onOpenInPanel={openFileInPanel} + openInEditorMenuLabel={preferredEditorMenuLabel} + onReveal={ + canUseShellActions && revealInFileManagerLabel !== undefined + ? () => revealMarkdownFileInFileManager(fileLinkMeta) + : undefined + } + revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1588,7 +1977,10 @@ function ChatMarkdown({ }, a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + const fileLinkMeta = normalizedHref + ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? + resolveMarkdownFileLinkMeta(normalizedHref, cwd)) + : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; @@ -1618,9 +2010,20 @@ function ChatMarkdown({ event.stopPropagation(); const api = readLocalApi(); if (!api) return; + const pullRequest = resolveThreadPullRequest(href); + const currentPullRequest = + threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; + const threadLinkAction = + currentPullRequest != null && + matchesLinkedPullRequestUrl(currentPullRequest, href) + ? "unlink-from-thread" + : pullRequest === null + ? undefined + : "link-to-thread"; void showExternalLinkContextMenu({ href, canOpenInPreview, + threadLinkAction, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), openInPreview: async (target) => { @@ -1634,8 +2037,25 @@ function ChatMarkdown({ }, openExternal: (target) => api.shell.openExternal(target), copyLink: (target) => writeTextToClipboard(target, "link"), + updateThreadLink: updateThreadPullRequestLink, reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); + if ( + operation === "link-pull-request-to-thread" || + operation === "unlink-pull-request-from-thread" + ) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: + operation === "link-pull-request-to-thread" + ? "Unable to link pull request" + : "Unable to unlink pull request", + description: + cause instanceof Error ? cause.message : "The request failed.", + }), + ); + } }, }); }} @@ -1671,9 +2091,6 @@ function ChatMarkdown({ props.className, ); }, - img({ node: _node, title: _title, ...props }) { - return ; - }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); @@ -1690,6 +2107,32 @@ function ChatMarkdown({
); }, + img({ node: _node, title: _title, src, alt, ...props }) { + const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; + const altText = alt ?? ""; + const imageSource = classifyMarkdownImageSource(srcString, cwd); + if (imageSource._tag === "Direct") { + return ( + {altText} + ); + } + if (imageSource._tag === "WorkspaceFile" && threadRef) { + return ( + + ); + } + return ; + }, table({ node: _node, ...props }) { return ; }, @@ -1726,6 +2169,7 @@ function ChatMarkdown({ }, }; }, [ + canUseShellActions, cwd, diffThemeName, fileLinkParentSuffixByPath, @@ -1735,12 +2179,18 @@ function ChatMarkdown({ onTaskListChange, openFileInPanel, openInPreferredEditor, + openChangeRequestLink, openExternalLinkInPreview, openMarkdownFileInPreview, + preferredEditorMenuLabel, + resolveThreadPullRequest, resolvedTheme, + revealMarkdownFileInFileManager, + revealInFileManagerLabel, skills, text, threadRef, + updateThreadPullRequestLink, ]); /* eslint-enable react/no-unstable-nested-components */ diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx new file mode 100644 index 000000000000..37a0f27a0ba3 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -0,0 +1,147 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + resources: [] as Array, + assetState: "success" as "success" | "loading", +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../assets/assetUrls", () => ({ + useAssetUrlState: (_environmentId: unknown, resource: unknown) => { + testState.resources.push(resource); + return testState.assetState === "loading" + ? { _tag: "Loading" } + : { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; + }, +})); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../state/session", async (importOriginal) => ({ + ...(await importOriginal()), + usePreparedConnection: () => ({ _tag: "Loading" }), +})); +vi.mock("../state/entities", () => ({ + readThreadShell: () => null, + useProjects: () => [], +})); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => undefined, + matchesLinkedPullRequestUrl: () => false, + parseChangeRequestUrl: () => null, + useOpenChangeRequestLink: () => vi.fn(), +})); + +import ChatMarkdown from "./ChatMarkdown"; + +const threadRef = { + environmentId: EnvironmentId.make("env-windows"), + threadId: ThreadId.make("thread-windows"), +}; + +function render(markdown: string): string { + return renderToStaticMarkup( + , + ); +} + +function renderWithoutThread(markdown: string): string { + return renderToStaticMarkup(); +} + +describe("ChatMarkdown workspace images", () => { + beforeEach(() => { + testState.resources = []; + testState.assetState = "success"; + }); + + it("loads every Windows workspace path form through a signed asset URL", () => { + const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg"; + const html = render( + [ + "![relative](.t3/workspace-image.svg)", + `![absolute](${imagePath})`, + `![file URL](file:///${imagePath})`, + "![UNC file URL](file://server/share/workspace-image.svg)", + ].join("\n\n"), + ); + + expect(testState.resources).toEqual([ + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", + }, + { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "\\\\server\\share\\workspace-image.svg", + }, + ]); + expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); + expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); + expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); + expect(html).not.toContain("Image unavailable"); + }); + + it("normalizes a drive-absolute src in raw image HTML", () => { + const html = render(String.raw`raw`); + + expect(testState.resources).toEqual([ + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "D:/screens/workspace-image.svg", + }, + ]); + expect(html).toContain("https://signed.test/workspace-image.svg"); + }); + + it("uses a static placeholder while a signed asset URL loads", () => { + testState.assetState = "loading"; + + const html = render("![loading](.t3/workspace-image.svg)"); + + expect(html).toContain('aria-label="Loading image"'); + expect(html).not.toContain("animate-pulse"); + }); + + it("never passes a workspace source to a raw image when thread context is unavailable", () => { + const html = renderWithoutThread( + "![file URL](file:///C:/Users/shawn/project/workspace-image.svg)", + ); + + expect(testState.resources).toEqual([]); + expect(html).toContain("Image unavailable"); + expect(html).not.toContain("file://"); + }); + + it("blocks unsupported image schemes instead of passing them to a raw image", () => { + const html = render("![unsupported](content://media/image/1)"); + + expect(testState.resources).toEqual([]); + expect(html).toContain("Image unavailable"); + expect(html).not.toContain("content://"); + }); + + it("keeps remote images directly loadable", () => { + const html = render("![remote](https://example.com/image.png)"); + + expect(testState.resources).toEqual([]); + expect(html).toContain('src="https://example.com/image.png"'); + expect(html).toContain("max-w-[min(100%,30rem)]"); + expect(html).toContain("max-h-[30rem]"); + expect(html).not.toContain("Image unavailable"); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..cb814dace2e5 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -26,10 +26,15 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveBackgroundDraftWorkspaceOptions, + resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, + shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -39,6 +44,148 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("draft hero submission transition", () => { + it("does not dock the composer before a background submission", () => { + expect( + shouldDockDraftHeroForSubmission({ + isDraftHeroState: true, + activeThreadKey: "environment-local:thread-1", + submissionIntent: "background", + }), + ).toBe(false); + }); + + it("keeps the composer in the hero layout until navigation after server promotion", () => { + expect( + resolveDraftHeroState({ + isLocalDraftThread: false, + hasTimelineEntries: true, + isWorking: true, + draftHeroDockRequested: false, + backgroundSubmissionPending: true, + }), + ).toBe(true); + }); + + it("does not auto-navigate a background submission after server promotion", () => { + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef: { environmentId, threadId }, + serverThreadStarted: true, + backgroundSubmissionPending: true, + }), + ).toBeNull(); + }); +}); + +describe("shouldReleaseTimelineAnchorForToolActivity", () => { + const activeTurnId = TurnId.make("active-turn"); + const anchorMessageId = MessageId.make("anchored-message"); + const activeToolEntry = { + id: "tool-entry", + kind: "work" as const, + createdAt: now, + entry: { + id: "active-tool", + createdAt: now, + turnId: activeTurnId, + label: "Run command", + tone: "tool" as const, + command: "git status", + }, + }; + + it("releases the send anchor for tool activity in the active turn", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(true); + }); + + it("keeps the anchor while the user reads history", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: false, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(false); + }); + + it("ignores tool activity from earlier turns", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + ...activeToolEntry.entry, + turnId: TurnId.make("previous-turn"), + }, + }, + ], + }), + ).toBe(false); + }); + + it("ignores thinking and error rows without tool activity", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + id: "thinking-entry", + createdAt: now, + turnId: activeTurnId, + label: "Thinking", + tone: "thinking", + }, + }, + { + ...activeToolEntry, + id: "error-entry", + entry: { + id: "error-entry", + createdAt: now, + turnId: activeTurnId, + label: "Provider error", + tone: "error", + }, + }, + ], + }), + ).toBe(false); + }); + + it("does nothing without an anchor or running turn", () => { + const input = { + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }; + + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( + false, + ); + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( + false, + ); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); @@ -382,6 +529,23 @@ describe("resolveSendEnvMode", () => { }); }); +describe("resolveBackgroundDraftWorkspaceOptions", () => { + it("keeps New worktree selected without reusing the launched worktree", () => { + expect( + resolveBackgroundDraftWorkspaceOptions({ + envMode: "worktree", + branch: "main", + startFromOrigin: true, + }), + ).toEqual({ + envMode: "worktree", + branch: "main", + worktreePath: null, + startFromOrigin: true, + }); + }); +}); + describe("branchMismatchKey", () => { it("builds a key from thread id and both branches", () => { expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( @@ -583,6 +747,29 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(false); }); + it("keeps a follow-up active while its provider session is starting", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "connecting", + latestTurn: completedTurn, + latestUserMessageId: MessageId.make("message-followup"), + session: { + ...readySession, + status: "starting", + updatedAt: "2026-03-29T00:01:00.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + it("acknowledges a settled newer turn", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestTurn: completedTurn, session: readySession }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index d2f8d96fce0f..20c9ebb0ad80 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -2,6 +2,7 @@ import { type EnvironmentId, isProviderDriverKind, ProjectId, + type MessageId, type ModelSelection, type ProviderDriverKind, type ServerProvider, @@ -22,6 +23,8 @@ import { } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import { materializePastedText } from "@t3tools/shared/pastedText"; +import type { ComposerSubmissionIntent } from "../composer-logic"; +import type { TimelineEntry } from "../session-logic"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -30,6 +33,72 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function shouldDockDraftHeroForSubmission(input: { + isDraftHeroState: boolean; + activeThreadKey: string | null; + submissionIntent: ComposerSubmissionIntent; +}): boolean { + return ( + input.submissionIntent === "foreground" && + input.isDraftHeroState && + input.activeThreadKey !== null + ); +} + +export function shouldReleaseTimelineAnchorForToolActivity(input: { + anchorMessageId: MessageId | null; + liveFollowEnabled: boolean; + runningTurnId: TurnId | null; + timelineEntries: ReadonlyArray; +}): boolean { + if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) { + return false; + } + + return input.timelineEntries.some((timelineEntry) => { + if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) { + return false; + } + + const entry = timelineEntry.entry; + return ( + entry.tone === "tool" || + entry.itemType !== undefined || + entry.requestKind !== undefined || + (entry.command?.trim().length ?? 0) > 0 + ); + }); +} + +export function resolveDraftHeroState(input: { + isLocalDraftThread: boolean; + hasTimelineEntries: boolean; + isWorking: boolean; + draftHeroDockRequested: boolean; + backgroundSubmissionPending: boolean; +}): boolean { + if (input.backgroundSubmissionPending) { + return true; + } + return ( + input.isLocalDraftThread && + !input.hasTimelineEntries && + !input.isWorking && + !input.draftHeroDockRequested + ); +} + +export function resolveDraftPromotionNavigationTarget(input: { + serverThreadRef: ScopedThreadRef | null; + serverThreadStarted: boolean; + backgroundSubmissionPending: boolean; +}): ScopedThreadRef | null { + if (input.backgroundSubmissionPending) { + return null; + } + return input.serverThreadStarted ? input.serverThreadRef : null; +} + export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); return () => globalThis.clearTimeout(timeoutId); @@ -258,6 +327,24 @@ export function resolveSendEnvMode(input: { return input.isGitRepo ? input.requestedEnvMode : "local"; } +export function resolveBackgroundDraftWorkspaceOptions(input: { + envMode: DraftThreadEnvMode; + branch: string | null; + startFromOrigin: boolean; +}): { + envMode: DraftThreadEnvMode; + branch: string | null; + worktreePath: null; + startFromOrigin: boolean; +} { + return { + envMode: input.envMode, + branch: input.branch, + worktreePath: null, + startFromOrigin: input.envMode === "worktree" && input.startFromOrigin, + }; +} + export function cloneComposerImageForRetry( image: ComposerImageAttachment, ): ComposerImageAttachment { @@ -494,6 +581,7 @@ export async function waitForStartedServerThread( export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; + submissionIntent: ComposerSubmissionIntent; latestUserMessageId: ChatMessage["id"] | null; latestTurnTurnId: TurnId | null; latestTurnRequestedAt: string | null; @@ -505,7 +593,10 @@ export interface LocalDispatchSnapshot { export function createLocalDispatchSnapshot( activeThread: Thread | undefined, - options?: { preparingWorktree?: boolean }, + options?: { + preparingWorktree?: boolean; + submissionIntent?: ComposerSubmissionIntent; + }, ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; const session = activeThread?.session ?? null; @@ -513,6 +604,7 @@ export function createLocalDispatchSnapshot( return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), + submissionIntent: options?.submissionIntent ?? "foreground", latestUserMessageId: latestUserMessage?.id ?? null, latestTurnTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, @@ -539,6 +631,9 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } + if (input.phase === "connecting") { + return false; + } const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index af1199de73ad..91ae0a70ce14 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,12 +27,19 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; +import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { changeRequestAutoSettles, effectiveSettled, effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; +import { + codexFeedbackMessage, + parseCodexFeedbackCommand, + submitCodexFeedback, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; import { parseScopedThreadKey, scopedThreadKey, @@ -49,8 +56,12 @@ import { import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; -import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { materializePastedText } from "@t3tools/shared/pastedText"; +import { + getTerminalLabel, + nextTerminalId, + resolveTerminalSessionLabel, +} from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; import { @@ -75,12 +86,14 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, + type ComposerSubmissionIntent, parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { @@ -122,6 +135,7 @@ import { type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; @@ -170,6 +184,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + Minimize2Icon, PaperclipIcon, WifiOffIcon, } from "lucide-react"; @@ -187,7 +202,11 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, +} from "../providerInstances"; import { useClientSettings, useClientSettingsHydrated, @@ -195,9 +214,14 @@ import { } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; +import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; -import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; +import { + preventRepeatedTerminalCloseShortcut, + preventTerminalCloseShortcut, +} from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { derivePhysicalProjectKey, @@ -205,10 +229,15 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; +import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { + beginBackgroundDraftSubmissionByRef, + clearBackgroundDraftSubmissionByRef, + composerDraftHasUserContent, type ComposerImageAttachment, type DraftThreadEnvMode, + finalizePromotedDraftThreadByRef, + markPromotedDraftThreadByRef, useComposerDraftStore, type DraftId, } from "../composerDraftStore"; @@ -308,8 +337,15 @@ import { import { resolveDisplayedThreadPr, threadChangeRequestSnapshotsAtom, + useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + hasAvailableClaudeCompactionProvider, + hasDismissedResumeCompaction, + shouldOfferResumeCompaction, +} from "./chat/ContextWindowMeter.logic"; +import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -334,6 +370,8 @@ import { scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, + shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -344,6 +382,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveBackgroundDraftWorkspaceOptions, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -355,6 +394,12 @@ import { import type { ThreadSyncPhase } from "../threadSync"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; +import { + awaitAttachmentUploads, + getUploadedAttachments, + releaseAttachmentUploads, + startAttachmentUpload, +} from "../lib/attachmentUploadQueue"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; @@ -589,8 +634,10 @@ function useLocalDispatchState(input: { threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); - const latestUserMessageId = - input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null; + const latestUserMessage = input.activeThread?.messages.findLast( + (message) => message.role === "user", + ); + const latestUserMessageId = latestUserMessage?.id ?? null; const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -621,14 +668,16 @@ function useLocalDispatchState(input: { ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( - (options?: { preparingWorktree?: boolean }) => { + (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { const active = serverAcknowledgedLocalDispatch ? null : current; if (active) { - return active.preparingWorktree === preparingWorktree + const submissionIntent = options?.submissionIntent ?? active.submissionIntent; + return active.preparingWorktree === preparingWorktree && + active.submissionIntent === submissionIntent ? active - : { ...active, preparingWorktree }; + : { ...active, preparingWorktree, submissionIntent }; } return createLocalDispatchSnapshot(input.activeThread, options); }); @@ -640,8 +689,10 @@ function useLocalDispatchState(input: { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, + latestUserMessageAt: latestUserMessage?.createdAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, isSendBusy: activeLocalDispatch !== null, + backgroundSubmissionPending: localDispatch?.submissionIntent === "background", }; } @@ -1229,6 +1280,20 @@ function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +/** + * Drops the send-time anchored end space. That space is what holds a sent + * message near the top while its turn streams, and it keeps LegendList's + * maintainScrollAtEnd switched off for as long as it is installed — ChatView + * drives the streaming scrolls itself, but only in "anchoring-new-turn" mode. + * So every return to the live edge has to release the anchor too, otherwise the + * timeline settles into "following-end" with nothing following anything. + */ +function releaseChatTimelineAnchor( + current: T, +): T { + return current.messageId === null ? current : { ...current, messageId: null }; +} + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -1242,6 +1307,7 @@ function ChatViewContent(props: ChatViewProps) { const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); + const { settleThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -1267,6 +1333,9 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1346,6 +1415,9 @@ function ChatViewContent(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerHasUnsentContent = useComposerDraftStore((store) => + composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), + ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( @@ -1385,6 +1457,16 @@ function ChatViewContent(props: ChatViewProps) { const [optimisticRetractionsByMessageId, setOptimisticRetractionsByMessageId] = useState< Record >({}); + const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< + Record> + >({}); + const feedbackSubmissions = useMemo( + () => feedbackSubmissionsByThreadKey[routeThreadKey] ?? [], + [feedbackSubmissionsByThreadKey, routeThreadKey], + ); + const feedbackUploading = feedbackSubmissions.some( + (submission) => submission.status === "uploading", + ); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< @@ -1447,6 +1529,7 @@ function ChatViewContent(props: ChatViewProps) { const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const preDispatchCancellationLatchRef = useRef(createPreDispatchCancellationLatch()); + const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { @@ -1749,6 +1832,9 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; + const activeRunningTurnId = + (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ?? + (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null); // Reading a finished thread clears the sidebar's Done badge. The visit is // stamped at the turn's completion time — not now/updatedAt — so it clears // exactly the completion the user is looking at: a wake or completion that @@ -2103,6 +2189,10 @@ function ChatViewContent(props: ChatViewProps) { }, [], ); + const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; + const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; + const supportsAttachmentUploads = + attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2295,6 +2385,10 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const activeContextWindow = useMemo( + () => deriveLatestContextWindowSnapshot(threadActivities), + [threadActivities], + ); const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the @@ -2388,8 +2482,10 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt, + latestUserMessageAt, isPreparingWorktree, isSendBusy, + backgroundSubmissionPending, } = useLocalDispatchState({ activeThread, activeLatestTurn, @@ -2423,6 +2519,7 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn, activeThread?.session ?? null, localDispatchStartedAt, + latestUserMessageAt, ); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; @@ -2654,8 +2751,16 @@ function ChatViewContent(props: ChatViewProps) { return changed ? { ...message, attachments } : message; }); + const localMessages = [ + ...optimisticUserMessages, + ...feedbackSubmissions.flatMap((submission) => + submission.status === "interrupted" + ? [] + : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], + ), + ]; const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); - const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); + const pendingMessages = localMessages.filter((message) => !serverIds.has(message.id)); const allMessages = pendingMessages.length === 0 ? serverMessagesWithPreviewHandoff @@ -2667,6 +2772,7 @@ function ChatViewContent(props: ChatViewProps) { }, [ attachmentPreviewHandoffByMessageId, displayServerMessages, + feedbackSubmissions, optimisticRetractionsByMessageId, optimisticUserMessages, ]); @@ -2707,14 +2813,16 @@ function ChatViewContent(props: ChatViewProps) { const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; - const isDraftHeroState = shouldRenderEmptyThreadHero({ - routeKind, - timelineEntryCount: timelineEntries.length, - isWorking, - phase, - dockRequested: draftHeroDockRequested, - threadDetailLoading, - }); + const isDraftHeroState = + backgroundSubmissionPending || + shouldRenderEmptyThreadHero({ + routeKind, + timelineEntryCount: timelineEntries.length, + isWorking, + phase, + dockRequested: draftHeroDockRequested, + threadDetailLoading, + }); const [ attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, @@ -2762,12 +2870,12 @@ function ChatViewContent(props: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); - const activeRunningTurnId = activeThread?.session?.activeTurnId ?? null; + const activeSessionTurnId = activeThread?.session?.activeTurnId ?? null; const lastUserMessagePopWindowOpen = supportsThreadTurnRetraction && isLastUserMessagePopWindowOpen({ phase, - activeTurnId: activeRunningTurnId, + activeTurnId: activeSessionTurnId, timelineEntries, localTurnStartPending: isSendBusy, retractionPending, @@ -2806,6 +2914,29 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; + const compactionProviderAvailable = useMemo( + () => + hasAvailableClaudeCompactionProvider({ + providers: applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + ), + instanceId: activeProviderInstanceId, + lockedInstanceId: lockedProvider + ? (activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + null) + : null, + }), + [ + activeProviderInstanceId, + activeThread?.modelSelection.instanceId, + activeThread?.session?.providerInstanceId, + lockedProvider, + providerStatuses, + settings, + ], + ); const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { return ( @@ -2815,6 +2946,25 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = + useLocalStorage( + `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, + false, + Schema.Boolean, + ); + const nativeResumeCompactionDismissed = useMemo( + () => hasDismissedResumeCompaction(threadActivities), + [threadActivities], + ); + useEffect(() => { + if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) { + setResumeCompactionPermanentlyDismissed(true); + } + }, [ + nativeResumeCompactionDismissed, + resumeCompactionPermanentlyDismissed, + setResumeCompactionPermanentlyDismissed, + ]); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -3457,24 +3607,48 @@ function ChatViewContent(props: ChatViewProps) { ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. - const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; + const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( + (number: number) => { + if (!supportsPullRequests || !activeThreadRef) { + return; + } + const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id; + const repository = linkedThreadPullRequest?.repository ?? activeProjectRepository; + if (projectId === undefined || repository === null) return; + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId, + repository, + number, + }); + }, + [ + activeProject, + activeProjectRepository, + activeThreadRef, + linkedThreadPullRequest, + supportsPullRequests, + ], + ); + const openProjectPullRequest = useCallback( (number: number) => { if ( !supportsPullRequests || !activeThreadRef || !activeProject || - threadRepository === null + activeProjectRepository === null ) { return; } useRightPanelStore.getState().openPullRequest(activeThreadRef, { projectId: activeProject.id, - repository: threadRepository, + repository: activeProjectRepository, number, }); }, - [activeProject, activeThreadRef, supportsPullRequests, threadRepository], + [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; @@ -3593,6 +3767,24 @@ function ChatViewContent(props: ChatViewProps) { }, [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], ); + const requestCloseTerminal = useCallback( + (terminalId: string) => { + const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); + void confirmTerminalClose([label]).then((confirmed) => { + if (confirmed) closeTerminal(terminalId); + }); + }, + [activeTerminalLabelsById, closeTerminal], + ); + const requestClosePanelTerminal = useCallback( + (terminalId: string) => { + const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); + void confirmTerminalClose([label]).then((confirmed) => { + if (confirmed) closePanelTerminal(terminalId); + }); + }, + [activeTerminalLabelsById, closePanelTerminal], + ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; @@ -3667,11 +3859,33 @@ function ChatViewContent(props: ChatViewProps) { const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - cleanupRightPanelSurfaces([surface]); - useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); - syncActivePreviewSurface(); + const finishClose = () => { + cleanupRightPanelSurfaces([surface]); + useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + syncActivePreviewSurface(); + }; + if (surface.kind !== "terminal") { + finishClose(); + return; + } + const activeLabel = + activeTerminalLabelsById.get(surface.activeTerminalId) ?? + getTerminalLabel(surface.activeTerminalId); + const otherLabels = surface.terminalIds + .filter((terminalId) => terminalId !== surface.activeTerminalId) + .map( + (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId), + ); + void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => { + if (confirmed) finishClose(); + }); }, - [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], + [ + activeThreadRef, + activeTerminalLabelsById, + cleanupRightPanelSurfaces, + syncActivePreviewSurface, + ], ); const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { @@ -3916,16 +4130,38 @@ function ChatViewContent(props: ChatViewProps) { liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - setTimelineAnchor((current) => - current.messageId === null ? current : { ...current, messageId: null }, - ); + setTimelineAnchor(releaseChatTimelineAnchor); requestAnimationFrame(() => { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); + useLayoutEffect(() => { + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } + + if ( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId: timelineAnchorMessageId, + liveFollowEnabled: timelineLiveFollowEnabled, + runningTurnId: activeRunningTurnId, + timelineEntries, + }) + ) { + scrollToEnd(); + } + }, [ + activeRunningTurnId, + scrollToEnd, + timelineAnchorMessageId, + timelineEntries, + timelineLiveFollowEnabled, + ]); useEffect(() => { let removeListeners: (() => void) | null = null; let frame: number | null = null; @@ -4096,6 +4332,11 @@ function ChatViewContent(props: ChatViewProps) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); + // Reachable only once manual navigation has already broken follow, so + // the anchored turn framing is over: the user scrolled back to the live + // edge and expects the stream to stick to it again, exactly like the + // scroll-to-bottom pill. + setTimelineAnchor(releaseChatTimelineAnchor); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -4275,13 +4516,27 @@ function ChatViewContent(props: ChatViewProps) { // partition (same shell, same capability gate, same PR auto-settle input) // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); + const activeComposerTasksProgress = + activeLatestTurn !== null && !latestTurnSettled + ? (activeThreadShell?.planProgress ?? null) + : null; + const activeComposerTaskSteps = + activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId + ? activePlan.steps + : null; const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); + const linkedPullRequestStatus = useLinkedThreadPullRequest( + activeThreadRef?.environmentId ?? null, + linkedThreadPullRequest, + ); const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, + linkedPullRequest: linkedThreadPullRequest, + linkedPullRequestStatus, }); // The right panel offers the thread's own change request, so it can only offer it once the // branch has one; until then the picker says so rather than opening an empty panel. @@ -4454,18 +4709,6 @@ function ChatViewContent(props: ChatViewProps) { // Dismissal lives in a module-level set (survives remounts); this tick just // forces a re-render so the banner leaves immediately. const [, setBranchMismatchDismissTick] = useState(0); - const composerHasDraftContent = useComposerDraftStore((store) => { - const draft = store.getComposerDraft(composerDraftTarget); - return Boolean( - draft && - (draft.prompt.trim().length > 0 || - draft.images.length > 0 || - draft.terminalContexts.length > 0 || - draft.elementContexts.length > 0 || - draft.previewAnnotations.length > 0 || - draft.reviewComments.length > 0), - ); - }); const activeBranchMismatchKey = branchMismatchKey( activeThread?.id ?? null, localCheckoutBranchMismatch, @@ -4473,7 +4716,7 @@ function ChatViewContent(props: ChatViewProps) { const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ hasMismatch: localCheckoutBranchMismatch !== null, isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), - composerHasContent: composerHasDraftContent, + composerHasContent: composerHasUnsentContent, wasShownForCurrentMismatch: revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, }); @@ -4611,13 +4854,13 @@ function ChatViewContent(props: ChatViewProps) { ), title: working ? liveCount > 0 - ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background` - : "Background work running" - : "Monitoring in the background", + ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working` + : "Background work" + : "Monitoring", actions: ( + ); + return { + id: `resume-compaction:${resumeCompactionKey}`, + variant: "info", + icon: , + title: "Resume with less context", + description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from an older session`, + actions: compactDisabledReason ? ( + + {compactAction}} /> + {compactDisabledReason} + + ) : ( + compactAction + ), + dismissLabel: "Keep full history", + onDismiss: dismiss, + }; + }, [ + activeContextWindow, + activeThread, + compactDisabled, + compactDisabledReason, + composerRef, + dismissedResumeCompactionKeys, + nativeResumeCompactionDismissed, + nowMinute, + pendingUserInputs.length, + phase, + resumeCompactionKey, + resumeCompactionPermanentlyDismissed, + selectedProvider, + ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -4711,6 +5055,8 @@ function ChatViewContent(props: ChatViewProps) { const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; + const resumeCompactionItems = + resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { @@ -4718,6 +5064,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, ...parkedThreadItems, ]; @@ -4726,6 +5073,7 @@ function ChatViewContent(props: ChatViewProps) { ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, + ...resumeCompactionItems, ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, @@ -4775,11 +5123,11 @@ function ChatViewContent(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, wokeThreadBannerItem, ]); - useEffect(() => { setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); @@ -4864,6 +5212,13 @@ function ChatViewContent(props: ChatViewProps) { event.stopPropagation(); return; } + // While a close confirmation is open, terminal focus has moved to the + // dialog, so a deliberate second close shortcut would otherwise fall + // through to the native window/tab close accelerator. + if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) { + event.stopPropagation(); + return; + } if (!activeThreadId || isCommandPaletteOpen()) { return; } @@ -4894,6 +5249,29 @@ function ChatViewContent(props: ChatViewProps) { }); if (!command) return; + if (command === "thread.settle") { + event.preventDefault(); + event.stopPropagation(); + if (!isServerThread || !activeThreadRef || !supportsSettlement) return; + if (activeThreadSettled) { + void handleUnsettleActiveThread(); + return; + } + + void settleThread(activeThreadRef).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + return; + } + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); @@ -4947,11 +5325,11 @@ function ChatViewContent(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { - closePanelTerminal(activeRightPanelSurface.activeTerminalId); + requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId); return; } if (!terminalUiState.terminalOpen) return; - closeTerminal(terminalUiState.activeTerminalId); + requestCloseTerminal(terminalUiState.activeTerminalId); return; } @@ -4997,18 +5375,24 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeRightPanelSurface, addTerminalSurface, + activeThreadRef, + activeThreadSettled, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, - closeTerminal, - closePanelTerminal, + requestCloseTerminal, + requestClosePanelTerminal, createNewTerminal, setTerminalOpen, runProjectScript, splitTerminal, splitPanelTerminal, keybindings, + handleUnsettleActiveThread, + isServerThread, onToggleDiff, + settleThread, + supportsSettlement, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -5086,6 +5470,7 @@ function ChatViewContent(props: ChatViewProps) { const onSend = async ( e?: { preventDefault: () => void }, + submissionIntent: ComposerSubmissionIntent = "foreground", directAnnotation?: { annotation: PreviewAnnotationPayload; image: ComposerImageAttachment | null; @@ -5126,7 +5511,8 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy || isConnecting || threadDetailLoading || - sendInFlightRef.current + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) ) { notifyDirectAnnotationAttached(); return; @@ -5201,6 +5587,101 @@ function ChatViewContent(props: ChatViewProps) { composerPreviewAnnotations.length + composerReviewComments.length, }); + const feedbackCommand = + ctxSelectedProvider === "codex" && + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0 + ? parseCodexFeedbackCommand(trimmed) + : null; + if (feedbackCommand) { + if (!isServerThread || activeThread.session === null) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Start a Codex thread first", + description: "Send a message before you submit feedback.", + }), + ); + return; + } + feedbackUploadsInFlightRef.current.add(routeThreadKey); + const result = await submitCodexFeedback({ + submission: { + id: newMessageId(), + command: trimmed, + createdAt: new Date().toISOString(), + }, + clearDraft: () => { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + scrollToEnd(); + }, + onUpdate: (submission) => { + setFeedbackSubmissionsByThreadKey((current) => { + const existing = current[routeThreadKey] ?? []; + const found = existing.some((entry) => entry.id === submission.id); + return { + ...current, + [routeThreadKey]: found + ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) + : [...existing, submission], + }; + }); + }, + upload: () => + uploadThreadFeedback({ + environmentId, + input: { + threadId: activeThread.id, + ...feedbackCommand, + }, + }), + }).finally(() => { + feedbackUploadsInFlightRef.current.delete(routeThreadKey); + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not send feedback to OpenAI", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return; + } + const feedbackId = result.value.feedbackId; + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Feedback sent to OpenAI", + description: `Thread ID: ${feedbackId}`, + timeout: 0, + actionProps: { + children: "Copy ID", + onClick: () => { + void writeTextToClipboard(feedbackId, "Codex feedback thread ID").catch( + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy thread ID", + description: chatActionErrorMessage(error), + }), + ); + }, + ); + }, + }, + }), + ); + return; + } if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed.length > 0 ? stripInlineTerminalContextPlaceholders(promptForSend) : "", @@ -5330,7 +5811,28 @@ function ChatViewContent(props: ChatViewProps) { }); preDispatchCancellationLatchRef.current.arm(messageIdForSend); sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { + if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) { + for (const image of composerImagesSnapshot) { + startAttachmentUpload({ environmentId, image }); + } + await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id)); + if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) { + sendInFlightRef.current = false; + setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending."); + return; + } + } + + const resolvedSubmissionIntent = + submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; + if ( + shouldDockDraftHeroForSubmission({ + isDraftHeroState, + activeThreadKey, + submissionIntent: resolvedSubmissionIntent, + }) && + activeThreadKey + ) { let resolveDockStarted: (() => void) | undefined; const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; @@ -5354,17 +5856,29 @@ function ChatViewContent(props: ChatViewProps) { ); return; } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + beginLocalDispatch({ + preparingWorktree: Boolean(baseBranchForWorktree), + submissionIntent: resolvedSubmissionIntent, + }); const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - })), + composerImagesSnapshot.map(async (image) => { + if (supportsAttachmentUploads) { + const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0]; + if (!uploaded) { + throw new Error(`Image '${image.name}' did not finish uploading.`); + } + return uploaded; + } + return { + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + }; + }), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, @@ -5374,21 +5888,25 @@ function ChatViewContent(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); - // Sending always returns to the live edge. The new row becomes the - // anchored end-space target so it lands near the top while the response - // streams into the reserved space below it. - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); + const shouldAnchorFirstMessage = + activeThread.latestTurn === null && + !timelineMessages.some((message) => message.role === "user"); + if (shouldAnchorFirstMessage) { + isAtEndRef.current = true; + timelineScrollModeRef.current = "anchoring-new-turn"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); + pendingTimelineAnchorRef.current = messageIdForSend; + activeTimelineAnchorIndexRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + setTimelineAnchor({ + threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageId: messageIdForSend, + }); + } else { + scrollToEnd(); + } setOptimisticUserMessages((existing) => [ ...existing, { @@ -5523,6 +6041,13 @@ function ChatViewContent(props: ChatViewProps) { preDispatchCancellationLatchRef.current.isCancelled(messageIdForSend); } else { beginLocalDispatch({ preparingWorktree: false }); + const backgroundThreadRef = + resolvedSubmissionIntent === "background" + ? scopeThreadRef(activeThread.environmentId, threadIdForSend) + : null; + if (backgroundThreadRef) { + beginBackgroundDraftSubmissionByRef(backgroundThreadRef); + } const startResult = await startThreadTurn({ environmentId, input: { @@ -5542,10 +6067,63 @@ function ChatViewContent(props: ChatViewProps) { }, }); if (startResult._tag === "Failure") { + if (backgroundThreadRef) { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } failure = startResult; } else { turnStartSucceeded = true; + if (supportsAttachmentUploads) { + releaseAttachmentUploads(composerImagesSnapshot); + } acknowledgeActiveThreadWoke(); + if (backgroundThreadRef) { + markPromotedDraftThreadByRef(backgroundThreadRef); + try { + const nextDraft = await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ); + if (nextDraft) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, + }), + ); + } else { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } + } catch (error) { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + resetLocalDispatch(); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Task started in the background", + description: + error instanceof Error + ? `Could not open a fresh composer: ${error.message}` + : "Could not open a fresh composer.", + }), + ); + } + } } } } @@ -5588,6 +6166,20 @@ function ChatViewContent(props: ChatViewProps) { } if (failure !== null && !isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); + if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { + const failedDraftSession = getDraftSession(draftId); + if (failedDraftSession?.threadId === threadIdForSend) { + setLogicalProjectDraftThreadId( + failedDraftSession.logicalProjectKey, + scopeProjectRef(failedDraftSession.environmentId, failedDraftSession.projectId), + draftId, + { + threadId: newThreadId(), + createdAt: new Date().toISOString(), + }, + ); + } + } setThreadError( threadIdForSend, error instanceof Error ? error.message : "Failed to send message.", @@ -5840,19 +6432,7 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - // Position this sent row once LegendList has measured the anchored tail. - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); + scrollToEnd(); setOptimisticUserMessages((existing) => [ ...existing, @@ -5948,6 +6528,7 @@ function ChatViewContent(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + scrollToEnd, setComposerDraftInteractionMode, setThreadError, startThreadTurn, @@ -6463,7 +7044,7 @@ function ChatViewContent(props: ChatViewProps) { configuredUrls={configuredPreviewUrls} visible onSendAnnotation={(annotation, image) => { - void onSend(undefined, { annotation, image }); + void onSend(undefined, "foreground", { annotation, image }); }} /> @@ -6518,7 +7099,7 @@ function ChatViewContent(props: ChatViewProps) { context={ isThreadOwnPullRequest( { - projectId: activeProject?.id ?? null, + projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null, repository: threadRepository, number: activeThreadPr?.number ?? null, }, @@ -6531,7 +7112,6 @@ function ChatViewContent(props: ChatViewProps) { ? "thread" : "page" } - chromeVariant="collapse" composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> @@ -6570,6 +7150,8 @@ function ChatViewContent(props: ChatViewProps) { setDragActive: setIsWorkspaceFileDragActive, addFiles: (files) => composerRef.current?.addDroppedFiles(files), }); + const externalComposerDrawerAttached = + composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable); return (
@@ -6590,9 +7172,9 @@ function ChatViewContent(props: ChatViewProps) { > {!rightPanelOpen ? panelLayoutControls : null} )} {threadSyncPhase && !activeEnvironmentUnavailable ? ( - + ) : null}
@@ -6786,6 +7361,8 @@ function ChatViewContent(props: ChatViewProps) { composerRef={composerRef} composerDraftTarget={composerDraftTarget} environmentId={environmentId} + attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} + supportsAttachmentUploads={supportsAttachmentUploads} routeKind={routeKind} routeThreadRef={routeThreadRef} draftId={draftId} @@ -6799,8 +7376,15 @@ function ChatViewContent(props: ChatViewProps) { phase={presentedPhase} isConnecting={isConnecting} isSendBusy={isSendBusy || heldSendPending} - sendDisabledReason={threadDetailLoading ? "Messages loading" : null} + sendDisabledReason={ + feedbackUploading + ? "Sending feedback" + : threadDetailLoading + ? "Messages loading" + : null + } isPreparingWorktree={isPreparingWorktree} + externalDrawerAttached={externalComposerDrawerAttached} environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} pendingApprovals={pendingApprovals} @@ -6813,6 +7397,8 @@ function ChatViewContent(props: ChatViewProps) { respondingRequestIds={respondingRequestIds} showPlanFollowUpPrompt={showPlanFollowUpPrompt} activeProposedPlan={activeProposedPlan} + activeTasksProgress={activeComposerTasksProgress} + activeTaskSteps={activeComposerTaskSteps} runtimeMode={runtimeMode} interactionMode={interactionMode} lockedProvider={lockedProvider} @@ -6821,7 +7407,9 @@ function ChatViewContent(props: ChatViewProps) { activeProject?.defaultModelSelection } activeThreadModelSelection={activeThread?.modelSelection} - activeThreadActivities={activeThread?.activities} + activeContextWindow={activeContextWindow} + compactDisabled={compactDisabled} + compactDisabledReason={compactDisabledReason} resolvedTheme={resolvedTheme} settings={settings} keybindings={keybindings} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index eae1db84634a..e3529257c5d4 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -6,6 +6,8 @@ import { getCloneDestinationBrowsePath, getCloneDestinationPath, getCloneDirectoryName, + getDefaultCloneUrl, + normalizePastedCloneUrl, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; @@ -93,7 +95,13 @@ import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; -import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; +import { + cn, + getLocalFileManagerName, + isMacPlatform, + isWindowsPlatform, + newProjectId, +} from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { useDiscoverableThreadShells } from "./chat/useDiscoverableThreadShells"; @@ -146,7 +154,7 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; -import { CommandDialog, CommandDialogPopup } from "./ui/command"; +import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -174,16 +182,6 @@ function projectFavicon(project: Project) { ); } -function getLocalFileManagerName(platform: string): string { - if (isMacPlatform(platform)) { - return "Finder"; - } - if (isWindowsPlatform(platform)) { - return "Explorer"; - } - return "Files"; -} - function getEnvironmentBrowsePlatform(os: string | null | undefined): string { if (os === "windows") { return "Win32"; @@ -1875,7 +1873,7 @@ function OpenCommandPaletteDialog(props: { source: addProjectCloneFlow.source, repositoryInput: rawRepository, repository: null, - remoteUrl: rawRepository, + remoteUrl: normalizePastedCloneUrl(rawRepository), }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -1915,7 +1913,7 @@ function OpenCommandPaletteDialog(props: { source: addProjectCloneFlow.source, repositoryInput: rawRepository, repository, - remoteUrl: repository.sshUrl, + remoteUrl: getDefaultCloneUrl(repository), }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -2417,17 +2415,14 @@ function OpenCommandPaletteDialog(props: { : undefined; const footerTrailing = canOpenProjectFromFileManager ? ( - + ) : null; return ( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 45de9796e05c..8f5655065fa0 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -78,12 +78,11 @@ import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, - COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME, SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; -import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; +import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; @@ -270,7 +269,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }} /> - {props.skillLabel} + {props.skillLabel} ); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..cd0854e176b7 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -68,23 +68,26 @@ export const JujutsuIcon: Icon = (props) => { ); }; -export const GitLabIcon: Icon = (props) => ( +export const GitLabIcon = ({ + monochrome = false, + ...props +}: SVGProps & { readonly monochrome?: boolean }) => ( ); diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 3456598c5d2c..c8d1645d1b06 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -20,6 +20,7 @@ import { terminalStatusFromRunningIds, ThreadStatusLabel, ThreadWorktreeIndicator, + useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; @@ -73,7 +74,9 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; +import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { readThreadShell, useProject, useProjects } from "../state/entities"; @@ -412,7 +415,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -453,11 +456,18 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr lastVisitedAt, }, }); - const pr = resolveThreadPr({ - threadBranch: thread.branch, - gitStatus: gitStatus.data, - }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const linkedPullRequestStatus = useLinkedThreadPullRequest( + thread.environmentId, + thread.linkedPullRequest, + ); + const pr = + thread.linkedPullRequest == null + ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) + : (linkedPullRequestStatus?.pr ?? null); + const prStatus = prStatusIndicator( + pr, + linkedPullRequestStatus?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, + ); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -1459,6 +1469,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return result; } const draftStore = useComposerDraftStore.getState(); + releaseProjectDraftUploads(memberProjectRef); const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); @@ -3073,6 +3084,7 @@ export default function LegacySidebar() { const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); + const terminalFocused = useTerminalFocus(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const environmentLabelById = useMemo( @@ -3395,7 +3407,7 @@ export default function LegacySidebar() { [threadJumpCommandByKey], ); const sidebarShortcutContext = { - terminalFocus: false, + terminalFocus: terminalFocused, terminalOpen: routeTerminalOpen, modelPickerOpen: isModelPickerOpen(), }; diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 223960f8314d..2ee06a6b6620 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -31,6 +31,7 @@ import { parseWslDistroFromInstanceId, providerUpdateNotificationKey, resolveEnvironmentUpdateRowStatus, + shouldShowPrimaryProviderUpdateToast, type LocalEnvironmentProvidersInput, type LocalEnvironmentUpdateGroup, type LocalProviderUpdateOutcome, @@ -325,6 +326,21 @@ describe("provider update launch notification logic", () => { type: "loading", title: "Updating provider", }); + expect(shouldShowPrimaryProviderUpdateToast(view)).toBe(false); + }); + + it("keeps the initial prompt and terminal outcomes visible as toasts", () => { + expect( + shouldShowPrimaryProviderUpdateToast( + getProviderUpdateInitialToastView({ + updateProviders: [updateCandidate({ driver: driver("codex") })], + oneClickProviders: [updateCandidate({ driver: driver("codex") })], + }), + ), + ).toBe(true); + expect( + shouldShowPrimaryProviderUpdateToast(getProviderUpdateRejectedToastView(1, "boom")), + ).toBe(true); }); it("uses server failure state for failed progress", () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 55999d2a31d8..8d8abf73e312 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -231,6 +231,10 @@ export function getProviderUpdateInitialToastView(input: { }; } +export function shouldShowPrimaryProviderUpdateToast(view: ProviderUpdateToastView): boolean { + return view.phase !== "running"; +} + export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { return { phase: "running", diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index 00112ccec198..639f07c38c13 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -16,8 +16,8 @@ import { getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, - getProviderUpdateRunningToastView, providerUpdateNotificationKey, + shouldShowPrimaryProviderUpdateToast, type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast"; @@ -31,7 +31,6 @@ type ActiveProviderUpdateToast = | { readonly kind: "update"; readonly key: string; - readonly toastId: ProviderUpdateToastId; readonly providerInstanceIds: ReadonlySet; readonly providerCount: number; }; @@ -57,20 +56,16 @@ function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) ); } -function updateProviderUpdateToast(input: { - readonly toastId: ProviderUpdateToastId; +function addProviderUpdateToast(input: { readonly view: ProviderUpdateToastView; - readonly openSettings: () => void; + readonly openSettings: (toastId: ProviderUpdateToastId) => void; }) { if (input.view.type === "loading" || input.view.type === "success") { - toastManager.update(input.toastId, { + return toastManager.add({ type: input.view.type, title: input.view.title, description: input.view.description, timeout: 0, - // Base UI merges toast updates and omits `undefined` keys, so `undefined` - // would leave the prompt's Update button in place. Replace it with a - // defined empty action so the CTA cannot linger while the update runs. actionProps: hiddenToastActionProps, data: { hideCopyButton: true, @@ -79,11 +74,10 @@ function updateProviderUpdateToast(input: { : {}), }, }); - return; } - toastManager.update( - input.toastId, + let toastId!: ProviderUpdateToastId; + toastId = toastManager.add( stackedThreadToast({ type: input.view.type, title: input.view.title, @@ -91,7 +85,7 @@ function updateProviderUpdateToast(input: { timeout: 0, actionProps: { children: "Settings", - onClick: input.openSettings, + onClick: () => input.openSettings(toastId), }, actionVariant: "outline", data: { @@ -99,10 +93,7 @@ function updateProviderUpdateToast(input: { }, }), ); -} - -function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { - return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; + return toastId; } /** @@ -126,10 +117,10 @@ export function ProviderUpdatePrimaryNotification() { useEffect(() => { return () => { const activeToast = activeToastRef.current; - if (activeToast) { + if (activeToast?.kind === "prompt") { toastManager.close(activeToast.toastId); - activeToastRef.current = null; } + activeToastRef.current = null; }; }, []); @@ -149,10 +140,14 @@ export function ProviderUpdatePrimaryNotification() { const activeToast = activeToastRef.current; if (toastId !== undefined) { toastManager.close(toastId); - } else if (activeToast) { + } else if (activeToast?.kind === "prompt") { toastManager.close(activeToast.toastId); } - if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { + if ( + activeToast && + (toastId === undefined || + (activeToast.kind === "prompt" && activeToast.toastId === toastId)) + ) { activeToastRef.current = null; } void navigate({ to: "/settings/providers" }); @@ -173,15 +168,12 @@ export function ProviderUpdatePrimaryNotification() { providers: activeProviders, providerCount: activeToast.providerCount, }); - updateProviderUpdateToast({ - toastId: activeToast.toastId, - view, - openSettings: () => openProviderSettings(activeToast.toastId), - }); - - if (isTerminalProviderUpdateToastView(view)) { - activeToastRef.current = null; + if (!shouldShowPrimaryProviderUpdateToast(view)) { + return; } + + addProviderUpdateToast({ view, openSettings: openProviderSettings }); + activeToastRef.current = null; }, [providers, openProviderSettings]); useEffect(() => { @@ -219,19 +211,15 @@ export function ProviderUpdatePrimaryNotification() { const providerCount = oneClickProviders.length; const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId)); - activeToastRef.current = { + const activeUpdate: ActiveProviderUpdateToast = { kind: "update", key: notificationKey, - toastId, providerInstanceIds, providerCount, }; + activeToastRef.current = activeUpdate; - updateProviderUpdateToast({ - toastId, - view: getProviderUpdateRunningToastView(providerCount), - openSettings, - }); + toastManager.close(toastId); void (async () => { const results = []; @@ -248,16 +236,15 @@ export function ProviderUpdatePrimaryNotification() { } const activeUpdateToast = activeToastRef.current; - if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { + if (activeUpdateToast !== activeUpdate) { return; } const failedMessage = firstFailedProviderUpdateMessage(results); if (failedMessage) { - updateProviderUpdateToast({ - toastId, + addProviderUpdateToast({ view: getProviderUpdateRejectedToastView(providerCount, failedMessage), - openSettings, + openSettings: openProviderSettings, }); activeToastRef.current = null; return; @@ -271,13 +258,8 @@ export function ProviderUpdatePrimaryNotification() { providers: updatedProviderSnapshots, providerCount, }); - updateProviderUpdateToast({ - toastId, - view, - openSettings, - }); - - if (isTerminalProviderUpdateToastView(view)) { + if (shouldShowPrimaryProviderUpdateToast(view)) { + addProviderUpdateToast({ view, openSettings: openProviderSettings }); activeToastRef.current = null; } })(); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 1812aa10260b..7b0ae9b4c201 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,7 +2,12 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { RightPanelTabs, surfaceShortcutActionForKey, tabMuteMenuItem } from "./RightPanelTabs"; +import { + RightPanelTabs, + surfaceShortcutActionForKey, + surfaceShortcutTargetsTypingContext, + tabMuteMenuItem, +} from "./RightPanelTabs"; function shortcutEvent( key: string, @@ -166,6 +171,33 @@ describe("surface shortcuts", () => { }); }); +describe("surface shortcut typing contexts", () => { + // Selector-aware stub: closest() answers only tokens the combined selector + // would actually match, mirroring how the browser resolves it. + const makeTarget = (matches: string | null) => ({ + closest(selectors: string) { + if (matches === null || !selectors.includes(matches)) return null; + return {}; + }, + }); + + it("treats form fields and every editable region as typing contexts", () => { + expect(surfaceShortcutTargetsTypingContext(makeTarget("input"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(makeTarget("textarea"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(makeTarget("select"))).toBe(true); + // The chat composer is a contenteditable that sits empty until a draft + // exists; launcher letters claimed from it redirected prompts into shells. + // The :not clause sees past contenteditable="false" islands to an editable + // host around them, so nested editors stay protected too. + expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]"))).toBe(true); + }); + + it("claims letters when focus sits outside any editable region", () => { + expect(surfaceShortcutTargetsTypingContext(null)).toBe(false); + expect(surfaceShortcutTargetsTypingContext(makeTarget(null))).toBe(false); + }); +}); + describe("RightPanelTabs audio indicator", () => { // A muted tab only shows the indicator while it is actually making sound: // arming mute on a quiet tab is deliberate and stays invisible until there diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f48c9ca07e4c..9d057a3d2980 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -10,7 +10,6 @@ import { TerminalSquare, Volume2, VolumeOff, - X, } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, @@ -33,6 +32,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { faviconUrlForOrigin } from "~/lib/favicon"; import { useTheme } from "~/hooks/useTheme"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -190,6 +190,23 @@ export function surfaceShortcutActionForKey< ); } +/** + * A focused editable is a typing context whether or not it has text yet: an + * empty chat composer at rest is still where the user's next keystrokes are + * meant to land, and claiming launcher letters from it would redirect prompts + * into whatever surface opens. The `:not` clause lets `closest` see past + * non-editable islands (`contenteditable="false"`) to an editable host around + * them, matching ComposerPendingUserInputPanel's typing guard. + */ +export function surfaceShortcutTargetsTypingContext( + target: { closest(selectors: string): unknown } | null, +): boolean { + return ( + target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != + null + ); +} + function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( @@ -329,13 +346,7 @@ function RightPanelEmptyState(props: { if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; const target = event.target; - if (target instanceof HTMLElement) { - if (target.closest("input, textarea, select")) return; - // An empty contenteditable (the chat composer at rest) does not - // count as typing; letters only become text once a draft exists. - const editable = target.isContentEditable ? target : target.closest("[contenteditable]"); - if (editable && (editable.textContent ?? "").trim().length > 0) return; - } + if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); action.onClick(); @@ -812,29 +823,24 @@ export function RightPanelTabs(props: RightPanelTabsProps) { : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - + ) : null} + {audio === "none" || !audioRuntimeTabId ? null : ( { + const baseArgs: Parameters[0] = { + active: null, + containerId: "pinned-threads", + isDragging: false, + isSorting: false, + id: "thread-a", + index: 1, + items: ["thread-b", "thread-a"], + newIndex: 0, + previousItems: ["thread-a", "thread-b"], + previousContainerId: "pinned-threads", + transition: { duration: 200, easing: "ease" }, + wasDragging: true, + }; + + it("does not replay layout movement after the pointer is released", () => { + expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true); + expect(animatePinnedLayoutChanges(baseArgs)).toBe(false); + }); + + it("keeps layout movement while the user is sorting", () => { + expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; @@ -779,6 +807,33 @@ describe("sortThreadsForSidebar", () => { expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); }); + + it("surfaces an un-settled thread at the top via its re-entry stamp", () => { + const sorted = sortThreadsForSidebar([ + { + id: "old-unsettled", + createdAt: "2026-03-09T08:00:00.000Z", + unsettledAt: "2026-03-09T13:00:00.000Z", + }, + sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), + sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]); + }); + + it("ignores a re-entry stamp older than the thread's creation", () => { + const sorted = sortThreadsForSidebar([ + { + id: "stale-stamp", + createdAt: "2026-03-09T10:00:00.000Z", + unsettledAt: "2026-03-09T09:00:00.000Z", + }, + sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "stale-stamp"]); + }); }); describe("pinOrderKeyBetween", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 2a763bf86d08..8c18f1cdc7ad 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,7 +1,9 @@ import * as React from "react"; +import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { + activeThreadAnchorTimestampMs, getThreadSortTimestamp, sortThreads, toSortableTimestamp, @@ -14,7 +16,7 @@ import { isLatestTurnSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; +export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. Each prewarmed // thread holds a live, fully hydrated detail subscription (all messages and @@ -23,6 +25,12 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; // it small; cold opens still render instantly from the cached snapshot. export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; +// The list already reaches its destination through sortable transforms while +// the pointer is down. dnd-kit's default also animates the committed DOM order +// after release, replaying the same movement across every affected row. +export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) => + args.isSorting ? defaultAnimateLayoutChanges(args) : false; + type SidebarProject = { id: string; title: string; @@ -544,16 +552,23 @@ export function firstValidTimestamp( return null; } -// Sidebar sort: static creation order, newest thread on top. Activity NEVER -// reorders the list — a row holds its position from open until settled, so -// the screen only moves at lifecycle transitions. Status (including pending -// approval) is carried by each card's edge strip, not by position. +// Sidebar sort: static order, newest anchor on top. Activity NEVER reorders +// the list — a row holds its position between lifecycle transitions, so the +// screen only moves when a thread enters or leaves the active list. The +// anchor is creation time until an un-settle re-anchors it (see +// activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the +// top instead of sinking back to its creation-order slot. Status (including +// pending approval) is carried by each card's edge strip, not by position. export function sortThreadsForSidebar< - T extends { readonly id: string; readonly createdAt: string }, + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + }, >(threads: readonly T[]): T[] { return [...threads].toSorted( (left, right) => - parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || left.id.localeCompare(right.id), ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 90782829c87e..d83a3b2241cb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -81,11 +81,13 @@ import { threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; +import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { @@ -119,6 +121,7 @@ import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { + animatePinnedLayoutChanges, buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, firstValidTimestampMs, @@ -137,6 +140,7 @@ import { sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, sortThreadsForSidebar, + useThreadJumpHintVisibility, } from "./Sidebar.logic"; import { useRetractedTurnPresentationSuppressed } from "./chat/retractedTurnPresentation"; import { useDiscoverableThreadShells } from "./chat/useDiscoverableThreadShells"; @@ -153,6 +157,7 @@ import { threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, type TerminalStatusIndicator, + useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { resolveSnoozePresets, @@ -164,11 +169,10 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { - deriveProviderInstanceEntries, + deriveProviderEntriesByEnvironment, shouldShowInstanceBadge, type ProviderInstanceEntry, } from "../providerInstances"; -import { primaryServerProvidersAtom } from "../state/server"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; @@ -313,6 +317,8 @@ function WorkingDuration(props: { startedAt: string | null }) { return {formatWorkingDurationLabel(Date.now() - startedMs)}; } +const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); + function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } @@ -515,6 +521,7 @@ function SortablePinnedThreadRow(props: { }) { const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id, + animateLayoutChanges: animatePinnedLayoutChanges, }); return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } @@ -717,6 +724,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { // The /draft/$draftId route redirects home on its own when the draft // it renders disappears, so discarding the open draft needs no // special-casing here. + releaseComposerDraftUploads(draftId); clearDraftThread(draftId); }, [clearDraftThread], @@ -764,13 +772,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; - // Renders the pin glyph. Pinned cards keep the full settle/snooze quick - // actions: settling clears the pin server-side, and snoozing hides the - // card until wake with the pin intact underneath. The glyph is also the - // in-row pin state cue (the pinned block has no header), so it always - // shows while pinned; it only becomes a clickable unpin quick-action once - // the pinning capability is confirmed, and stays a passive marker while - // the descriptor is not loaded. Pinning itself lives in the context menu. + // Pinned threads show the same pin marker in active, settled, and snoozed + // rows. The marker can unpin the thread when the server supports pinning. pinningSupported: boolean; isPinned: boolean; // Present only on pinned cards whose server supports reordering: dnd-kit @@ -853,6 +856,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const terminalProcessCount = runningTerminalIds.length; const gitCwd = thread.worktreePath ?? props.projectCwd; + const linkedPullRequestStatus = useLinkedThreadPullRequest( + thread.environmentId, + thread.linkedPullRequest, + ); const gitStatus = useEnvironmentQuery( (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ @@ -867,6 +874,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, + linkedPullRequest: thread.linkedPullRequest, + linkedPullRequestStatus, }); // Same semantics as the legacy sidebar (never-visited counts as read): @@ -978,6 +987,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, + linkedPullRequest: thread.linkedPullRequest, + linkedPullRequestStatus, }); const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; @@ -987,15 +998,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, + linkedPullRequest: thread.linkedPullRequest, + linkedPullRequestStatus, }); if (nextSnapshot === undefined) return; onChangeRequestSnapshot(threadKey, nextSnapshot); }, [ changeRequestSnapshot, gitStatus.data, + linkedPullRequestStatus, onChangeRequestSnapshot, retainTerminalOnBranchMismatch, thread.branch, + thread.linkedPullRequest, threadKey, ]); @@ -1272,6 +1287,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + const pinIndicator = props.isPinned ? ( + props.pinningSupported ? ( + + + } + > + + + Unpin thread + + ) : ( + + ) + ) : null; if (variant === "slim") { return ( @@ -1313,6 +1353,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> {title} + {pinIndicator} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1378,17 +1419,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - + + + } + > + + + Un-settle thread + ) : ( )} -
+
{terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; - const closeTerminalLabel = `Close ${ - terminalLabelById.get(terminalId) ?? "terminal" - }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; + const terminalLabel = terminalLabelById.get(terminalId) ?? "Terminal"; + const closeTerminalLabel = `Close ${terminalLabel}${ + isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : "" + }`; return (
- {showGroupHeaders && ( - + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} + > + confirmCloseTerminal(terminalId)} + tooltip={closeTerminalLabel} + > + + - {normalizedTerminalIds.length > 1 && ( - - onCloseTerminal(terminalId)} - aria-label={closeTerminalLabel} - /> - } - > - - - - {closeTerminalLabel} - - - )}
); })} diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 1212030ba339..906bf4c34cb4 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -66,7 +66,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { className={cn( "flex items-center justify-between gap-2 rounded-xl", expanded && - "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]", + "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--contrast-foreground)_2.5%,var(--background))]", )} > - -
- ) : null} - +
+ ) : null} + {isTasksDrawerOpen && + !hasBlockingComposerTopDrawer && + visibleTasksProgress && + visibleTaskSteps ? ( + + ) : null} +
+ {showShoulderTabs && visibleTasksProgress && visibleTaskSteps ? ( + 0} + onDismiss={dismissTasks} + onToggle={toggleTasksDrawer} + progress={visibleTasksProgress} + steps={visibleTaskSteps} + /> + ) : null} + {showShoulderTabs ? ( + + ) : null} +
- - - {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( - - setIsStashMenuOpen(false)} - /> - - )} - - {composerMenuOpen && !isComposerApprovalState && ( - - - - )} - - {!isMobileViewport && (isComposerExpandAvailable || isComposerExpanded) ? ( - - event.preventDefault()} - onClick={() => setIsComposerExpanded((expanded) => !expanded)} - > - {isComposerExpanded ? ( - - ) : ( - - )} - - } - /> - - {isComposerExpanded ? "Collapse" : "Expand"} - - + {showCollapsedMobilePromptRow ? ( +
+ + {inlineTasksBadge} + {inlineStashBadge} + +
) : null} - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerPreviewAnnotations.length > 0 && ( - - removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) - } - onExpandImage={(imageId) => { - const preview = buildExpandedImagePreview(composerImages, imageId); - if (preview) onExpandImage(preview); - }} - className="mb-3" - /> +
0 && ( - - removeComposerDraftReviewComment(composerDraftTarget, commentId) - } - className="mb-3" - /> + > + {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( + + setIsStashMenuOpen(false)} + /> + )} - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerElementContexts.length > 0 && ( - - removeComposerDraftElementContext(composerDraftTarget, contextId) - } - className="mb-3" - /> + {composerMenuOpen && !isComposerApprovalState && ( + + + )} - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.some( - (image) => - !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), - ) && ( -
- {composerImages - .filter( - (image) => - !composerPreviewAnnotations.some( - (annotation) => annotation.id === image.id, - ), - ) - .map((image) => ( -
+ event.preventDefault()} + onClick={() => setIsComposerExpanded((expanded) => !expanded)} > - {image.previewUrl ? ( - + {isComposerExpanded ? ( + ) : ( -
- {image.name} -
+ )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - + } + /> + + {isComposerExpanded ? "Collapse" : "Expand"} + + + ) : null} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerPreviewAnnotations.length > 0 && ( + + retryAttachmentUpload({ environmentId, image }), + } + : {})} + onRemove={(annotationId) => { + releaseAttachmentUpload(annotationId); + removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); + }} + onExpandImage={(imageId) => { + const preview = buildExpandedImagePreview(composerImages, imageId); + if (preview) onExpandImage(preview); + }} + className="mb-3" + /> + )} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerReviewComments.length > 0 && ( + + removeComposerDraftReviewComment(composerDraftTarget, commentId) + } + className="mb-3" + /> + )} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerElementContexts.length > 0 && ( + + removeComposerDraftElementContext(composerDraftTarget, contextId) + } + className="mb-3" + /> + )} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.some( + (image) => + !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), + ) && ( +
+ {composerImages + .filter( + (image) => + !composerPreviewAnnotations.some( + (annotation) => annotation.id === image.id, + ), + ) + .map((image) => { + const upload = supportsAttachmentUploads + ? uploadsByImageId[image.id] + : undefined; + return ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + {upload?.status === "uploading" && ( + + {formatAttachmentUploadProgress(upload.progress)} + + )} + {upload?.status === "failed" && ( + + + retryAttachmentUpload({ environmentId, image }) + } + aria-label={`Retry upload for ${image.name}`} + /> + } + > + + + - - - } - /> - + + )} + -
- ))} -
- )} + + +
+ ); + })} +
+ )} + +
+ + {showMobilePendingAnswerActions ? ( +
+ {inlineTasksBadge} + {inlineStashBadge} + +
+ ) : null} +
+
-
- + + {/* Bottom toolbar */} + {isComposerCollapsedMobile || isComposerApprovalState ? null : ( +
0 && "pt-2", + isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", + showMobilePendingAnswerActions && "hidden sm:flex", )} - onBeyondMinimumHeightChange={handleComposerBeyondMinimumHeightChange} - onRemoveTerminalContext={removeComposerTerminalContextFromDraft} - onChange={onPromptChange} - onCommandKeyDown={onComposerCommandKey} - onPaste={onComposerPaste} - placeholder={ - isComposerApprovalState - ? (activePendingApproval?.detail ?? "Resolve this approval request to continue") - : activePendingProgress - ? "Type your own answer, or leave this blank to use the selected option" - : showPlanFollowUpPrompt && activeProposedPlan - ? "Add feedback to refine the plan, or leave this blank to implement it" - : projectSelectionRequired - ? "Choose a project above to start a thread" - : noProviderAvailable - ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? DISCONNECTED_COMPOSER_PLACEHOLDER - : (latestPromptSuggestion ?? - "Ask anything, @tag files/folders, $use skills, or / for commands") - } - disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} - /> - {showMobilePendingAnswerActions ? ( + > +
+ {noProviderAvailable ? ( + + ) : ( + { + setIsComposerModelPickerOpen(open); + }} + getModelDisabledReason={getModelDisabledReason} + onInstanceModelChange={onProviderModelSelect} + /> + )} + + {isComposerFooterCompact ? ( + + ) : ( + <> + {providerTraitsPicker ? ( + <> + + {providerTraitsPicker} + + ) : null} + + + )} +
+ + {/* Right side: send / stop button */}
- 0} isSendBusy={isSendBusy} sendDisabledReason={sendDisabledReason} isConnecting={isConnecting} @@ -3172,145 +3637,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) noProviderAvailable || projectSelectionRequired } - isPreparingWorktree={false} - hasSendableContent={false} - preserveComposerFocusOnPointerDown + isPreparingWorktree={isPreparingWorktree} + hasSendableContent={composerSendState.hasSendableContent} + preserveComposerFocusOnPointerDown={isMobileViewport} + showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} - /> -
- ) : null} -
-
- - - - {/* Bottom toolbar */} - {isComposerCollapsedMobile ? null : activePendingApproval ? ( -
- -
- ) : ( -
0 && "pt-2", - isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", - showMobilePendingAnswerActions && "hidden sm:flex", - )} - > -
- {noProviderAvailable ? ( - - ) : ( - { - setIsComposerModelPickerOpen(open); - }} - getModelDisabledReason={getModelDisabledReason} - onInstanceModelChange={onProviderModelSelect} - /> - )} - - {isComposerFooterCompact ? ( - - ) : ( - <> - {providerTraitsPicker ? ( - <> - - {providerTraitsPicker} - - ) : null} - - - )} -
- - {/* Right side: send / stop button */} -
- 0} - isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} - isConnecting={isConnecting} - isEnvironmentUnavailable={ - environmentUnavailable !== null || - noProviderAvailable || - projectSelectionRequired - } - isPreparingWorktree={isPreparingWorktree} - hasSendableContent={composerSendState.hasSendableContent} - preserveComposerFocusOnPointerDown={isMobileViewport} - showSendWhileRunning={isMobileViewport} - onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={handleInterruptPrimaryAction} - onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} - /> +
-
- )} + )} +
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index d032b16a186b..dbba327489ac 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -15,6 +15,7 @@ import { ChevronDownIcon } from "lucide-react"; import { memo, useCallback, + useEffect, useMemo, useRef, useState, @@ -22,6 +23,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import GitActionsControl from "../GitActionsControl"; +import { isTrailingDoubleClick } from "../Sidebar.logic"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; @@ -88,6 +90,14 @@ export function resolveRenameCommit(input: { return { action: "commit", title: trimmed }; } +// How long a click on the thread title waits before opening the action menu, +// so a double-click-to-rename can cancel it first. Only the native desktop +// menu needs this: it swallows input while open, so the wait must cover the +// OS double-click interval. The browser fallback menu keeps seeing DOM +// events (the second click dismisses it and dblclick still fires), so it +// opens immediately. +const TITLE_MENU_OPEN_DELAY_MS = 500; + export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; @@ -188,28 +198,77 @@ export const ChatHeader = memo(function ChatHeader({ }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], ); - const { openMenu } = useThreadActionMenu({ + const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, changeRequest, onStartRename: startRename, }); const titleButtonRef = useRef(null); - const openMenuFromTitle = useCallback(() => { + const titleMenuTimerRef = useRef(null); + const cancelPendingTitleMenu = useCallback(() => { + if (titleMenuTimerRef.current === null) return; + clearTimeout(titleMenuTimerRef.current); + titleMenuTimerRef.current = null; + }, []); + // Drop a pending menu-open when the thread changes or the header unmounts, + // so it can never fire for a thread the user already left. + useEffect( + () => () => { + cancelPendingTitleMenu(); + }, + [activeThreadId, cancelPendingTitleMenu], + ); + const openTitleMenuNow = useCallback(() => { + cancelPendingTitleMenu(); const rect = titleButtonRef.current?.getBoundingClientRect(); if (!rect) return; openMenu({ x: rect.left, y: rect.bottom + 4 }); - }, [openMenu]); + }, [cancelPendingTitleMenu, openMenu]); + const openMenuFromTitle = useCallback( + (event: ReactMouseEvent) => { + // The trailing click of a double-click belongs to rename, not the menu. + if (isTrailingDoubleClick(event.detail)) return; + // Keyboard activation and the explicit chevron affordance can never be + // the first half of a double-click, so they open without waiting. + const clickedChevron = + (event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null; + if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) { + openTitleMenuNow(); + return; + } + // Stay pending long enough for dblclick to cancel the open before the + // native menu appears and swallows the second click. + cancelPendingTitleMenu(); + titleMenuTimerRef.current = window.setTimeout(() => { + titleMenuTimerRef.current = null; + openTitleMenuNow(); + }, TITLE_MENU_OPEN_DELAY_MS); + }, + [cancelPendingTitleMenu, openTitleMenuNow], + ); + const handleTitleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // The chevron is the explicit menu affordance; only the title text renames. + if ((event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null) return; + cancelPendingTitleMenu(); + closeMenu(); + startRename(); + }, + [cancelPendingTitleMenu, closeMenu, startRename], + ); const handleHeaderContextMenu = useCallback( (event: ReactMouseEvent) => { if (!isServerThread || renamingTitle !== null) return; // The right-side controls (git, scripts, open-in) keep their own // behavior; only the breadcrumb area opens the thread menu. if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return; + cancelPendingTitleMenu(); event.preventDefault(); openMenu({ x: event.clientX, y: event.clientY }); }, - [isServerThread, openMenu, renamingTitle], + [cancelPendingTitleMenu, isServerThread, openMenu, renamingTitle], ); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -285,6 +344,8 @@ export const ChatHeader = memo(function ChatHeader({ aria-label={`Thread actions for ${activeThreadTitle}`} aria-haspopup="menu" onClick={openMenuFromTitle} + onDoubleClick={handleTitleDoubleClick} + onBlur={cancelPendingTitleMenu} className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" /> } @@ -292,6 +353,7 @@ export const ChatHeader = memo(function ChatHeader({

{activeThreadTitle}

diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index 6eed4fb05315..33d0d17eed7d 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -36,7 +36,9 @@ describe("ComposerBannerStack", () => { const neutralBehind = renderToStaticMarkup( , ); - expect(neutralBehind).toContain("border-border"); + expect(neutralBehind).toContain("chat-composer-banner-stack-cap"); + expect(neutralBehind).toContain("border-[var(--chat-composer-attached-outline)]"); + expect(neutralBehind).not.toContain("border-border"); expect(neutralBehind).not.toContain("border-warning/24"); const warningBehind = renderToStaticMarkup( @@ -49,12 +51,15 @@ describe("ComposerBannerStack", () => { const markup = renderToStaticMarkup(); expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); - expect(markup).toContain("alert-glass"); + expect(markup).toContain("chat-composer-drawer-surface"); + expect(markup).toContain("chat-composer-drawer-attached"); + expect(markup).not.toContain("before:mask-none"); + expect(markup).toContain("text-xs"); + expect(markup).toContain('data-composer-banner-drawer="true"'); expect(markup).toContain('data-variant="warning"'); expect(markup).toContain("transform:none"); expect(markup).not.toContain("will-change:transform"); }); - it("applies item-specific surface and action layout classes", () => { const markup = renderToStaticMarkup( { expect(markup).toContain("branch-surface"); expect(markup).toContain("branch-actions"); }); + + it("renders a disabled compaction action on the shared accessible banner surface", () => { + const markup = renderToStaticMarkup( +