diff --git a/.github/scripts/check-source-stack.sh b/.github/scripts/check-source-stack.sh new file mode 100644 index 00000000000..02fda2cc7be --- /dev/null +++ b/.github/scripts/check-source-stack.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Verify that a manually supplied nightly source contains every fork patch +# present on main. This catches accidentally stale stacks; it is not a +# security boundary or a net-tree comparison (a later revert can still retain +# an earlier commit's patch-id). The refs must already exist locally, and the +# fork's squash-merged patch stack is expected to have linear history. +set -euo pipefail + +main_ref="${1:?main ref is required}" +candidate_ref="${2:?candidate ref is required}" +upstream_ref="${3:?upstream ref is required}" +waived_input="${4:-}" + +for ref in "$main_ref" "$candidate_ref" "$upstream_ref"; do + git rev-parse --verify "${ref}^{commit}" >/dev/null +done + +# Waivers are the reviewed commits themselves, not a blanket override: a +# patch that merges to main after the review is not on the list and still +# fails the dispatch. The former allow_missing_main_patches boolean waived +# every missing patch at once, so it could not make that distinction. +if [[ "$waived_input" == "true" ]]; then + echo "The allow_missing_main_patches boolean was replaced by waived_main_patches; list each reviewed commit instead." >&2 + exit 1 +fi +if [[ "$waived_input" == "false" ]]; then + waived_input="" +fi + +waived_entries=() +normalized_waivers="${waived_input//[$'\n',]/ }" +read -r -a waived_entries <<< "$normalized_waivers" || true + +waived_commits=() +if (( ${#waived_entries[@]} > 0 )); then + for entry in "${waived_entries[@]}"; do + if ! commit=$(git rev-parse --verify --quiet "${entry}^{commit}"); then + echo "waived_main_patches entry '${entry}' does not resolve to a commit." >&2 + exit 1 + fi + waived_commits+=("$commit") + done +fi + +is_waived() { + local wanted="$1" waived + (( ${#waived_commits[@]} > 0 )) || return 1 + for waived in "${waived_commits[@]}"; do + if [[ "$waived" == "$wanted" ]]; then + return 0 + fi + done + return 1 +} + +describe_commit() { + printf ' %s %s\n' \ + "$(git rev-parse --short=12 "$1")" \ + "$(git show -s --format=%s "$1")" +} + +cherry_output=$(git cherry "$candidate_ref" "$main_ref" "$upstream_ref") + +missing_commits=() +while read -r status commit; do + if [[ "$status" == "+" ]]; then + missing_commits+=("$commit") + fi +done <<< "$cherry_output" + +if (( ${#missing_commits[@]} == 0 )); then + echo "source_ref contains every patch currently carried by main." + if (( ${#waived_commits[@]} > 0 )); then + echo "Every waived_main_patches entry is present after all; the waivers were not needed." >&2 + fi + exit 0 +fi + +waived_missing=() +unwaived_commits=() +for commit in "${missing_commits[@]}"; do + if is_waived "$commit"; then + waived_missing+=("$commit") + else + unwaived_commits+=("$commit") + fi +done + +if (( ${#waived_missing[@]} > 0 )); then + { + echo "Waived main patches missing from source_ref (reviewed as intentionally reshaped):" + echo + for commit in "${waived_missing[@]}"; do + describe_commit "$commit" + done + echo + } >&2 + echo "::warning title=Waived missing main patches::Continuing past ${#waived_missing[@]} reviewed patch-id difference(s) listed in the log." >&2 +fi + +if (( ${#unwaived_commits[@]} == 0 )); then + exit 0 +fi + +{ + echo "source_ref is missing patch-id-equivalent commits currently carried by main:" + echo + for commit in "${unwaived_commits[@]}"; do + describe_commit "$commit" + done + echo +} >&2 + +echo "Refresh the resolution from current main before dispatching it." >&2 +echo "If conflict resolution intentionally reshaped these patches, re-dispatch" >&2 +echo "listing each reviewed commit in waived_main_patches." >&2 +echo "::error title=source_ref is missing patches from main::Refresh the stack or waive each reviewed patch-id difference explicitly." >&2 +exit 1 diff --git a/.github/scripts/rebase-onto-upstream.sh b/.github/scripts/rebase-onto-upstream.sh new file mode 100644 index 00000000000..be147be9ee1 --- /dev/null +++ b/.github/scripts/rebase-onto-upstream.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Rebase the checked-out patch stack onto the tip of upstream main. Sourced by +# Fork CI's upstream-rebase job and Fork Nightly's prepare step so both use +# the same upstream remote and rebase mechanics. +set -euo pipefail + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +git remote add upstream https://github.com/pingdotgg/t3code.git +git fetch --no-tags upstream main +# shellcheck disable=SC2034 # Consumed by scripts that source this file. +UPSTREAM_REF=$(git rev-parse FETCH_HEAD) +git rebase "$UPSTREAM_REF" + +# The patch stack must never touch upstream's migration manifest or migration +# files. A fork commit in upstream's numbered sequence collides silently on a +# later rebase: the SQL migrator treats the highest recorded ID as a watermark, +# so existing installs skip the upstream migration that lands on the same slot. +offending_migration_files=$(git diff --name-only "$UPSTREAM_REF" HEAD -- \ + 'apps/server/src/persistence/Migrations.ts' \ + 'apps/server/src/persistence/Migrations/') +if [[ -n "$offending_migration_files" ]]; then + { + echo "Error: the fork patch stack modifies upstream migration files:" + echo + echo "$offending_migration_files" + echo + echo "Fork migrations belong in apps/server/src/persistence/ForkMigrations/" + echo "with their own append-only history. See docs/internals/fork-migrations.md." + } >&2 + exit 1 +fi diff --git a/.github/scripts/release-changelog.sh b/.github/scripts/release-changelog.sh new file mode 100644 index 00000000000..5b6fa1c602b --- /dev/null +++ b/.github/scripts/release-changelog.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +list_fork_release_commits() { + local previous_release_ref="$1" + local fork_source_ref="$2" + local upstream_ref="$3" + + if [[ -n "$previous_release_ref" ]]; then + git rev-list --reverse --cherry-pick --right-only \ + "${previous_release_ref}...${fork_source_ref}" \ + --not "$upstream_ref" + return + fi + + local fork_base + fork_base=$(git merge-base "$fork_source_ref" "$upstream_ref") + git rev-list --reverse "${fork_base}..${fork_source_ref}" --not "$upstream_ref" +} diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml new file mode 100644 index 00000000000..fbcc87e9091 --- /dev/null +++ b/.github/workflows/fork-ci.yml @@ -0,0 +1,231 @@ +name: Fork CI + +# The inherited CI workflow (ci.yml) targets upstream-owned Blacksmith runners +# and stays disabled in this fork. This fork-owned copy exists so runner choice +# is ours to make per job: the repository is public, so GitHub-hosted runners +# are free, so this fork uses GitHub-hosted runners throughout. +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: fork-ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + changes: + name: Fork Changes + if: github.repository == 'yngatech/t3code' + runs-on: ubuntu-24.04 + outputs: + run_ci: ${{ steps.filter.outputs.run_ci }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect non-documentation changes + id: filter + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + run_ci=true + changed_files="$RUNNER_TEMP/fork-ci-changed-files.txt" + + if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1 && + git rev-parse --verify "$HEAD_SHA^{commit}" >/dev/null 2>&1 && + git diff --name-only "$BASE_SHA...$HEAD_SHA" > "$changed_files" + then + run_ci=false + while IFS= read -r changed_file; do + case "$changed_file" in + *.md | docs/*) ;; + *) run_ci=true; break ;; + esac + done < "$changed_files" + else + echo "Could not classify changed files; running full CI." + fi + + echo "run_ci=$run_ci" >> "$GITHUB_OUTPUT" + + upstream_rebase: + name: Fork Upstream Rebase + # Every commit here joins the patch stack that Fork Nightly rebases onto + # upstream before each release, so fail fast when the stack no longer + # applies cleanly. Runs even for docs-only changes; those rebase too. + if: github.repository == 'yngatech/t3code' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # github.sha is the synthetic merge commit for pull_request runs. + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Rebase patch stack onto upstream + run: source .github/scripts/rebase-onto-upstream.sh + + check: + name: Fork Check + needs: changes + if: needs.changes.outputs.run_ci == 'true' + # This job normally finishes before the server and workspace test suites, + # so the public repository's free GitHub-hosted capacity keeps the overall + # CI critical path unchanged. + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # github.sha is the synthetic merge commit for pull_request runs. + ref: ${{ github.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: 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 + + - name: Check + run: vp check + + - 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 + + - name: Verify preload bundle output + run: | + test -f apps/desktop/dist-electron/preload.cjs + 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 + + test_server: + name: Fork Test (Server) + needs: changes + if: needs.changes.outputs.run_ci == 'true' + # The server suite disables Vitest file parallelism for stability. Keep it on + # its own runner so its long serial tail overlaps the other workspace tests. + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.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: true + + - name: Test server + run: vp run --filter t3 test + + test_workspace: + name: Fork Test (Workspace) + needs: changes + if: needs.changes.outputs.run_ci == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.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: 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 \ + --filter './apps/*' \ + --filter './infra/*' \ + --filter './packages/*' \ + --filter './oxlint-plugin-t3code' \ + --filter './scripts' \ + --filter '!./apps/server' \ + test --exclude '**/imageCompression.test.ts' + vp run --filter @t3tools/web test src/lib/imageCompression.test.ts \ + --no-file-parallelism \ + --maxWorkers=1 + + - name: Test resource monitor + run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + + release_smoke: + name: Fork Release Smoke + needs: changes + if: needs.changes.outputs.run_ci == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + - name: Exercise release-only workflow steps + run: node scripts/release-smoke.ts diff --git a/.github/workflows/fork-desktop-build-dispatch.yml b/.github/workflows/fork-desktop-build-dispatch.yml new file mode 100644 index 00000000000..03e4e47a887 --- /dev/null +++ b/.github/workflows/fork-desktop-build-dispatch.yml @@ -0,0 +1,35 @@ +name: Fork Desktop Build (manual) + +run-name: Build ${{ inputs.target }} from ${{ github.ref_name }} + +on: + workflow_dispatch: + inputs: + target: + description: Desktop target to build + required: true + default: macos-arm64 + type: choice + options: + - macos-arm64 + - linux-x64 + - windows-x64 + +permissions: + contents: read + id-token: none + +concurrency: + group: fork-desktop-build-${{ github.ref }}-${{ inputs.target }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ inputs.target }} + uses: ./.github/workflows/fork-desktop-build.yml + with: + ref: ${{ github.sha }} + version: 0.0.0-build.${{ github.run_number }} + target: ${{ inputs.target }} + sign_macos: false + check_notarization: false diff --git a/.github/workflows/fork-desktop-build.yml b/.github/workflows/fork-desktop-build.yml new file mode 100644 index 00000000000..4f81e814504 --- /dev/null +++ b/.github/workflows/fork-desktop-build.yml @@ -0,0 +1,389 @@ +name: Fork Desktop Build + +on: + workflow_call: + inputs: + ref: + description: Git ref to build + required: true + type: string + version: + description: Version to write into the desktop packages + required: true + type: string + target: + description: Desktop target to build + required: false + default: all + type: string + sign_macos: + description: Sign and notarize the macOS artifact + required: false + default: false + type: boolean + check_notarization: + description: Fail before building if the nightly notarization queue is active + required: false + default: false + type: boolean + force_notarization: + description: Ignore an active nightly notarization submission + required: false + default: false + type: boolean + secrets: + APPLE_API_KEY: + required: false + APPLE_API_KEY_ID: + required: false + APPLE_API_ISSUER: + required: false + CSC_KEY_PASSWORD: + required: false + CSC_LINK: + required: false + MACOS_PROVISIONING_PROFILE: + required: false + +permissions: + contents: read + id-token: none + +jobs: + configure: + name: Configure build matrix + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - id: matrix + name: Select targets + shell: bash + env: + SELECTED_TARGET: ${{ inputs.target }} + run: | + node <<'NODE' >> "$GITHUB_OUTPUT" + const targets = [ + { + id: "macos-arm64", + label: "macOS arm64", + runner: "macos-15", + platform: "mac", + target: "dmg", + arch: "arm64", + rust_target: "aarch64-apple-darwin", + artifact: "desktop-mac-arm64", + }, + { + id: "linux-x64", + label: "Linux x64", + runner: "ubuntu-24.04", + platform: "linux", + target: "AppImage", + arch: "x64", + rust_target: "x86_64-unknown-linux-gnu", + artifact: "desktop-linux-x64", + }, + { + id: "windows-x64", + label: "Windows x64", + // Least CPU-bound of the three targets (#51), so it takes the free runner. + runner: "windows-2025", + platform: "win", + target: "nsis", + arch: "x64", + rust_target: "x86_64-pc-windows-msvc", + artifact: "desktop-win-x64", + }, + ]; + + const selected = process.env.SELECTED_TARGET === "all" + ? targets + : targets.filter(({ id }) => id === process.env.SELECTED_TARGET); + if (selected.length === 0) { + throw new Error(`Unknown desktop build target: ${process.env.SELECTED_TARGET}`); + } + + process.stdout.write(`matrix=${JSON.stringify({ include: selected })}\n`); + NODE + + # node-pty publishes no Linux prebuilt and the WSL backend runs under the + # distro's own (Linux) Node. Build it on Linux for the Windows package so WSL + # works without a first-launch compiler, node-gyp, or network connection. + build_wsl_node_pty: + name: Build WSL node-pty x64 + needs: configure + if: inputs.target == 'all' || inputs.target == 'windows-x64' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3... + + - name: Build node-pty linux-x64 prebuild + shell: bash + run: | + set -euo pipefail + pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" + pty_dir="$(dirname "$pty_pkg")" + ( cd "$pty_dir" && npx --yes node-gyp rebuild ) + mkdir -p wsl-prebuild + cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node + file wsl-prebuild/pty.node + + - name: Upload node-pty linux-x64 prebuild + uses: actions/upload-artifact@v7 + with: + name: wsl-node-pty-x64 + path: wsl-prebuild/pty.node + if-no-files-found: error + + build: + name: Build ${{ matrix.label }} + needs: [configure, build_wsl_node_pty] + # A failed WSL prebuild should fail the Windows matrix entry at its download + # step without preventing independent macOS and Linux entries from running. + if: ${{ !cancelled() && needs.configure.result == 'success' }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 100 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.configure.outputs.matrix) }} + steps: + - name: Check Apple notarization queue + if: >- + matrix.platform == 'mac' && + inputs.sign_macos && + inputs.check_notarization && + !inputs.force_notarization + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + umask 077 + + required=(APPLE_API_KEY APPLE_API_KEY_ID APPLE_API_ISSUER) + for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + echo "Missing required Apple notarization configuration: $name" >&2 + exit 1 + fi + done + + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + history_json=$(xcrun notarytool history \ + --key "$key_path" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER" \ + --output-format json) + + active_submission=$(jq -r ' + .history + | map(select( + .name == "T3 Code (yngatech Nightly).zip" and + .status == "In Progress" + )) + | sort_by(.createdDate) + | last + | if . == null then empty else [.id, .createdDate] | @tsv end + ' <<< "$history_json") + + if [[ -n "$active_submission" ]]; then + IFS=$'\t' read -r submission_id created_date <<< "$active_submission" + echo "::error title=Apple notarization already in progress::Submission $submission_id from $created_date is still in progress. Wait for Apple to finish, or dispatch Fork Nightly with force_notarization enabled." + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/desktop... + - --filter=t3... + - --filter=@t3tools/scripts... + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + + - name: Align package versions + run: node scripts/update-release-package-versions.ts "${{ inputs.version }}" + + - name: Download WSL node-pty prebuild + if: matrix.platform == 'win' + uses: actions/download-artifact@v8 + with: + name: wsl-node-pty-x64 + path: wsl-prebuild + + - name: Install Spectre-mitigated MSVC libs + if: matrix.platform == 'win' + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installPath = & $vswhere -products * -latest -property installationPath + $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" + $proc = Start-Process -FilePath $setupExe ` + -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` + -Wait -PassThru -NoNewWindow + if ($null -eq $proc -or $proc.ExitCode -ne 0) { + $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } + Write-Error "Visual Studio Installer failed with exit code $code" + exit $code + } + + - name: Install ImageMagick + if: matrix.platform == 'linux' + shell: bash + run: | + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y imagemagick + fi + + if command -v magick >/dev/null 2>&1; then + magick -version + else + convert -version + fi + + - name: Build desktop artifact + timeout-minutes: 90 + shell: bash + env: + T3CODE_DESKTOP_UPDATE_REPOSITORY: ${{ github.repository }} + SIGN_MACOS: ${{ inputs.sign_macos }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} + run: | + set -euo pipefail + + args=( + --platform "${{ matrix.platform }}" + --target "${{ matrix.target }}" + --arch "${{ matrix.arch }}" + --build-version "${{ inputs.version }}" + --verbose + ) + + if [[ "${{ matrix.platform }}" == "mac" && "$SIGN_MACOS" == "true" ]]; then + required=( + CSC_LINK + CSC_KEY_PASSWORD + APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER + APPLE_TEAM_ID + MACOS_PROVISIONING_PROFILE + T3CODE_CLERK_PASSKEY_RP_DOMAINS + ) + for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + echo "Missing required Apple signing configuration: $name" >&2 + exit 1 + fi + done + + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + profile_plist="$RUNNER_TEMP/t3code-profile.plist" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" > "$profile_plist" + + expected_app_id="$APPLE_TEAM_ID.dev.incognitojam.t3code" + profile_app_id=$(/usr/libexec/PlistBuddy \ + -c 'Print :Entitlements:com.apple.application-identifier' \ + "$profile_plist") + if [[ "$profile_app_id" != "$expected_app_id" ]]; then + echo "Provisioning profile app ID is '$profile_app_id'; expected '$expected_app_id'." >&2 + exit 1 + fi + + if ! /usr/libexec/PlistBuddy \ + -c 'Print :Entitlements:com.apple.developer.associated-domains' \ + "$profile_plist" >/dev/null; then + echo "Provisioning profile does not enable Associated Domains." >&2 + exit 1 + fi + + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + args+=(--signed) + elif [[ "${{ matrix.platform }}" == "win" ]]; then + args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") + fi + + vp run dist:desktop:artifact "${args[@]}" + + - name: Collect release assets + shell: bash + run: | + set -euo pipefail + mkdir -p release-publish + + shopt -s nullglob + for pattern in \ + "release/*.dmg" \ + "release/*.zip" \ + "release/*.AppImage" \ + "release/*.exe" \ + "release/*.blockmap" \ + "release/*.yml"; do + for file in $pattern; do + [[ "$(basename "$file")" == "builder-debug.yml" ]] && continue + cp "$file" release-publish/ + done + done + + test -n "$(find release-publish -maxdepth 1 -type f -print -quit)" + + - name: Upload release assets + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact }} + path: release-publish/* + if-no-files-found: error diff --git a/.github/workflows/fork-nightly.yml b/.github/workflows/fork-nightly.yml new file mode 100644 index 00000000000..19154252035 --- /dev/null +++ b/.github/workflows/fork-nightly.yml @@ -0,0 +1,629 @@ +name: Fork Nightly + +on: + schedule: + - cron: "23 8,11,14,17,20 * * *" + workflow_dispatch: + inputs: + force_notarization: + description: Ignore an active Apple notarization submission + required: false + type: boolean + default: false + dry_run: + description: >- + Run the full pipeline even without new changes, but leave the + release as an unpublished draft and skip nightly promotion + required: false + type: boolean + default: false + source_ref: + description: >- + Publish this ref's patch stack instead of main's. A branch or SHA on + origin holding a conflict-resolved stack; it is rebased, verified, + released, and promoted to main like any other candidate + required: false + type: string + default: "" + waived_main_patches: + description: >- + Commits from main (space or comma separated SHAs) reviewed as + intentionally reshaped by conflict resolution and expected to be + missing from source_ref; any other missing patch still fails + required: false + type: string + default: "" + +permissions: + contents: write + id-token: none + +concurrency: + group: fork-nightly-publish + +jobs: + prepare: + name: Prepare and verify candidate + if: github.repository == 'yngatech/t3code' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + outputs: + has_changes: ${{ steps.candidate.outputs.has_changes }} + ref: ${{ steps.candidate.outputs.ref }} + # The patch stack this run publishes, before the rebase: main's tip, or + # source_ref's commit. Release notes enumerate this stack's commits. + fork_ref: ${{ steps.candidate.outputs.fork_ref }} + # main's tip when the run started, which promotion backs up and replaces. + # Same commit as fork_ref unless source_ref was given. + main_ref: ${{ steps.candidate.outputs.main_ref }} + upstream_ref: ${{ steps.candidate.outputs.upstream_ref }} + version: ${{ steps.release_meta.outputs.version }} + tag: ${{ steps.release_meta.outputs.tag }} + release_name: ${{ steps.release_meta.outputs.name }} + previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} + steps: + - name: Validate source ref + if: inputs.source_ref != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SOURCE_REF: ${{ inputs.source_ref }} + run: | + set -euo pipefail + + # Checking before checkout turns a typo or an unpushed branch into a + # readable first-step failure instead of a checkout error. + if ! gh api "repos/${GITHUB_REPOSITORY}/commits/${SOURCE_REF}" \ + --jq .sha; then + echo "source_ref '${SOURCE_REF}' does not resolve to a commit in ${GITHUB_REPOSITORY}; push the stack to origin first." >&2 + exit 1 + fi + + - id: app_token + name: Mint yngatech-nightly app token + # The yngatech-nightly GitHub App is the bypass actor on main's + # ruleset, so pushes to protected refs must authenticate as the app. + # Minted per job because installation tokens expire after one hour and + # the desktop builds run between this job and the release job. + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YNGATECH_NIGHTLY_APP_CLIENT_ID }} + private-key: ${{ secrets.YNGATECH_NIGHTLY_APP_PRIVATE_KEY }} + + - name: Checkout fork patch stack + uses: actions/checkout@v6 + with: + token: ${{ steps.app_token.outputs.token }} + ref: ${{ inputs.source_ref || 'main' }} + fetch-depth: 0 + + - id: candidate + name: Rebase candidate onto upstream + shell: bash + env: + WAIVED_MAIN_PATCHES: ${{ inputs.waived_main_patches }} + DRY_RUN: ${{ inputs.dry_run == true }} + SOURCE_REF: ${{ inputs.source_ref }} + run: | + set -euo pipefail + + # fork_ref is the stack this run publishes: main's tip, or the + # maintainer's resolved stack on a source_ref run. main_ref is the + # commit promotion replaces, read once here so every later main-side + # check compares against the same snapshot. + fork_ref=$(git rev-parse HEAD) + if [[ -n "${SOURCE_REF:-}" ]]; then + main_ref=$(git ls-remote origin refs/heads/main | cut -f1) + if [[ -z "$main_ref" ]]; then + echo "origin has no main branch to promote onto." >&2 + exit 1 + fi + + # Fetch the immutable main snapshot and current upstream before + # rebasing. The guard itself is loaded from main so a stale + # source_ref cannot omit or roll back the check it must pass. + git fetch --no-tags origin "$main_ref" + git fetch --no-tags https://github.com/pingdotgg/t3code.git main + guard_upstream_ref=$(git rev-parse FETCH_HEAD) + guard_script="$RUNNER_TEMP/check-source-stack.sh" + if ! git show "${main_ref}:.github/scripts/check-source-stack.sh" > "$guard_script"; then + echo "main snapshot ${main_ref} does not contain the source_ref patch guard; update main before dispatching a source_ref run." >&2 + exit 1 + fi + bash "$guard_script" \ + "$main_ref" \ + "$fork_ref" \ + "$guard_upstream_ref" \ + "$WAIVED_MAIN_PATCHES" + else + main_ref=$fork_ref + fi + + git fetch origin '+refs/heads/nightly:refs/remotes/origin/nightly' || true + git fetch origin \ + '+refs/heads/nightly-candidate:refs/remotes/origin/nightly-candidate' || true + + git switch -C nightly-candidate HEAD + source .github/scripts/rebase-onto-upstream.sh + upstream_ref=$UPSTREAM_REF + + candidate_ref=$(git rev-parse HEAD) + has_changes=true + if [[ "$DRY_RUN" != "true" ]] && \ + git rev-parse --verify refs/remotes/origin/nightly >/dev/null 2>&1 && \ + git diff --quiet origin/nightly HEAD --; then + has_changes=false + fi + + { + echo "has_changes=$has_changes" + echo "ref=$candidate_ref" + echo "fork_ref=$fork_ref" + echo "main_ref=$main_ref" + echo "upstream_ref=$upstream_ref" + } >> "$GITHUB_OUTPUT" + + - name: Align main to released nightly + if: >- + inputs.dry_run != true && + steps.candidate.outputs.has_changes == 'false' + shell: bash + env: + MAIN_REF: ${{ steps.candidate.outputs.main_ref }} + SOURCE_REF: ${{ inputs.source_ref }} + run: | + set -euo pipefail + + # No new upstream this run, so the release job (and its promotion + # step) will not run. The existing origin/nightly commit has a tree + # identical to the freshly rebased candidate (that is exactly what + # has_changes=false means) and already shipped through a fully + # verified release, so align main to it directly without + # re-verification. A source_ref run reaching here resolved to that + # same already-released tree, so origin/nightly is still the right + # commit to put on main. + if ! nightly_ref=$(git rev-parse --verify \ + refs/remotes/origin/nightly 2>/dev/null); then + echo "origin/nightly does not exist; nothing to align main to." + exit 0 + fi + if [[ "$MAIN_REF" == "$nightly_ref" ]]; then + echo "main already matches origin/nightly; skipping promotion." + exit 0 + fi + + # A PR merged since this run started moves main. On a scheduled run + # that is normal: skip, and the next candidate includes it. A + # source_ref run exists to put the resolution on main, so fail + # loudly instead (notify_failure fires) — nothing silently outlives + # a resolution that no longer contains main's tip. + remote_main=$(git ls-remote origin refs/heads/main | cut -f1) + if [[ "$remote_main" != "$MAIN_REF" ]]; then + if [[ -n "${SOURCE_REF:-}" ]]; then + echo "main moved during this source_ref run; refresh the resolution from current main and re-dispatch." >&2 + exit 1 + fi + echo "main moved during this run; skipping promotion — the next run's candidate will include it." + exit 0 + fi + + # The day's first promotion snapshots pre-promotion main; later + # promotions leave the snapshot alone. Same mechanics as the release + # job's "Promote verified stack to main" step; keep the two in sync. + backup_ref="refs/heads/backup/main-$(date -u +%Y%m%d)" + if git ls-remote --exit-code origin "$backup_ref" >/dev/null; then + echo "${backup_ref#refs/heads/} already exists; leaving it in place." + else + status=$? + if [[ "$status" -ne 2 ]]; then + echo "Checking origin for ${backup_ref} failed (git ls-remote exit ${status})." + exit "$status" + fi + git push origin "${MAIN_REF}:${backup_ref}" + fi + + # The lease pins main to the commit this run started from. A failed + # lease means main moved in the seconds since the check above; fail + # loudly and let notify_failure fire rather than promote over it. + git push --force-with-lease="refs/heads/main:${MAIN_REF}" \ + origin "${nightly_ref}:refs/heads/main" + + - name: Setup Vite+ + if: steps.candidate.outputs.has_changes == 'true' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + if: steps.candidate.outputs.has_changes == 'true' + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Setup Rust + if: steps.candidate.outputs.has_changes == 'true' + uses: dtolnay/rust-toolchain@stable + + - name: Check + if: steps.candidate.outputs.has_changes == 'true' + run: vp check + + - name: Typecheck + if: steps.candidate.outputs.has_changes == 'true' + run: vp run typecheck + + - name: Build desktop pipeline + if: steps.candidate.outputs.has_changes == 'true' + run: vp run build:desktop + + - name: Verify preload bundle output + if: steps.candidate.outputs.has_changes == 'true' + run: | + test -f apps/desktop/dist-electron/preload.cjs + 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 + + - name: Test + if: steps.candidate.outputs.has_changes == 'true' + run: | + vp run -r test --exclude '**/imageCompression.test.ts' + vp run --filter @t3tools/web test src/lib/imageCompression.test.ts \ + --no-file-parallelism \ + --maxWorkers=1 + + - name: Test resource monitor + if: steps.candidate.outputs.has_changes == 'true' + run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + + - id: release_meta + name: Resolve nightly version + if: steps.candidate.outputs.has_changes == 'true' + shell: bash + env: + CANDIDATE_REF: ${{ steps.candidate.outputs.ref }} + NIGHTLY_RUN_NUMBER: ${{ github.run_number }} + run: | + nightly_date=$(date -u +%Y%m%d) + node scripts/resolve-nightly-release.ts \ + --date "$nightly_date" \ + --run-number "$NIGHTLY_RUN_NUMBER" \ + --sha "$CANDIDATE_REF" \ + --github-output + + - id: previous_tag + name: Resolve previous fork nightly + if: steps.candidate.outputs.has_changes == 'true' + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel nightly \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + + - name: Publish verified candidate + if: steps.candidate.outputs.has_changes == 'true' + run: git push --force-with-lease origin HEAD:refs/heads/nightly-candidate + + build_desktop: + name: Build desktop artifacts + needs: prepare + if: needs.prepare.outputs.has_changes == 'true' + uses: ./.github/workflows/fork-desktop-build.yml + with: + ref: ${{ needs.prepare.outputs.ref }} + version: ${{ needs.prepare.outputs.version }} + target: all + sign_macos: true + check_notarization: true + force_notarization: ${{ inputs.force_notarization == true }} + secrets: inherit + + release: + name: Publish GitHub prerelease + needs: [prepare, build_desktop] + permissions: + contents: write + issues: write + pull-requests: read + if: >- + needs.prepare.outputs.has_changes == 'true' && + needs.build_desktop.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - id: app_token + name: Mint yngatech-nightly app token + # Fresh token rather than prepare's: installation tokens expire after + # one hour and the desktop builds run between the two jobs. This job's + # nightly and main pushes need the app's ruleset bypass too. + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YNGATECH_NIGHTLY_APP_CLIENT_ID }} + private-key: ${{ secrets.YNGATECH_NIGHTLY_APP_PRIVATE_KEY }} + + - name: Checkout released source + uses: actions/checkout@v6 + with: + token: ${{ steps.app_token.outputs.token }} + ref: ${{ needs.prepare.outputs.ref }} + fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Download release assets + uses: actions/download-artifact@v8 + with: + pattern: desktop-* + merge-multiple: true + path: release-assets + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + # Extraction reads every commit the fork carries, so without this each run + # re-reads the whole patch stack. Entries are keyed by patch ID, which the + # nightly rebase preserves; a miss only costs the extraction it skipped. + - name: Restore changelog extraction cache + uses: actions/cache@v4 + with: + path: ~/.cache/t3code-changelog + key: changelog-records-${{ github.run_id }} + restore-keys: changelog-records- + + - id: fork_features + name: Generate fork features and improvements + continue-on-error: true + env: + FORK_SOURCE_REF: ${{ needs.prepare.outputs.fork_ref }} + GITHUB_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_FORK_CHANGELOG_API_KEY }} + PREVIOUS_TAG: ${{ needs.prepare.outputs.previous_tag }} + UPSTREAM_REF: ${{ needs.prepare.outputs.upstream_ref }} + shell: bash + run: | + set -euo pipefail + + if [[ -z "$OPENAI_API_KEY" ]]; then + echo "OPENAI_FORK_CHANGELOG_API_KEY is not configured; keeping commit-based release notes." + echo "generated=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git fetch --no-tags origin "$FORK_SOURCE_REF" + node scripts/generate-fork-features-summary.ts \ + --fork-ref "$FORK_SOURCE_REF" \ + --upstream-ref "$UPSTREAM_REF" \ + --previous-release-ref "$PREVIOUS_TAG" \ + --fork-repository "$GITHUB_REPOSITORY" \ + --upstream-repository pingdotgg/t3code \ + --output fork-features.md \ + --nightly-output nightly-highlights.md \ + --records-cache "$HOME/.cache/t3code-changelog/records.json" \ + --exclude-mobile-only-nightly + echo "generated=true" >> "$GITHUB_OUTPUT" + + - name: Generate prerelease notes + shell: bash + env: + FORK_SOURCE_REF: ${{ needs.prepare.outputs.fork_ref }} + GH_TOKEN: ${{ github.token }} + PREVIOUS_TAG: ${{ needs.prepare.outputs.previous_tag }} + UPSTREAM_REF: ${{ needs.prepare.outputs.upstream_ref }} + run: | + set -euo pipefail + + git fetch --no-tags origin "$FORK_SOURCE_REF" + git rev-parse --verify "${FORK_SOURCE_REF}^{commit}" >/dev/null + git rev-parse --verify "${UPSTREAM_REF}^{commit}" >/dev/null + source .github/scripts/release-changelog.sh + + append_changes() { + local repository="$1" + shift + + if (( $# == 0 )); then + return + fi + + local sha short_sha subject title pull_number author + for sha in "$@"; do + short_sha="${sha:0:7}" + subject=$(git show -s --format=%s "$sha") + rendered_change_count=$((rendered_change_count + 1)) + if [[ "$subject" =~ ^(.+)\ \(#([0-9]+)\)$ ]]; then + title="${BASH_REMATCH[1]}" + pull_number="${BASH_REMATCH[2]}" + author=$(gh api "repos/${repository}/pulls/${pull_number}" \ + --jq '.user.login' 2>/dev/null || true) + if [[ -n "$author" ]]; then + printf -- '- %s ([%s#%s](https://github.com/%s/pull/%s)) by @%s\n' \ + "$title" "$repository" "$pull_number" "$repository" "$pull_number" "$author" + else + printf -- '- %s ([%s#%s](https://github.com/%s/pull/%s))\n' \ + "$title" "$repository" "$pull_number" "$repository" "$pull_number" + fi + else + # shellcheck disable=SC2016 # Markdown backticks must remain literal. + printf -- '- %s ([`%s`](https://github.com/%s/commit/%s))\n' \ + "$subject" "$short_sha" "$repository" "$sha" + fi + done + } + + if [[ -n "$PREVIOUS_TAG" ]]; then + git rev-parse --verify "${PREVIOUS_TAG}^{commit}" >/dev/null + previous_upstream_ref=$(git merge-base "$PREVIOUS_TAG" "$UPSTREAM_REF") + else + previous_upstream_ref=$(git merge-base "$FORK_SOURCE_REF" "$UPSTREAM_REF") + fi + + upstream_changes=() + while IFS= read -r sha; do + upstream_changes+=("$sha") + done < <(git rev-list --reverse "${previous_upstream_ref}..${UPSTREAM_REF}") + + fork_changes=() + while IFS= read -r sha; do + fork_changes+=("$sha") + done < <( + list_fork_release_commits "$PREVIOUS_TAG" "$FORK_SOURCE_REF" "$UPSTREAM_REF" + ) + + rendered_change_count=0 + { + if [[ -f nightly-highlights.md ]]; then + cat nightly-highlights.md + printf '\n' + fi + printf "## What's Changed\n\n" + if (( ${#fork_changes[@]} > 0 )); then + append_changes "$GITHUB_REPOSITORY" "${fork_changes[@]}" + fi + if (( ${#upstream_changes[@]} > 0 )); then + append_changes pingdotgg/t3code "${upstream_changes[@]}" + fi + if (( rendered_change_count == 0 )); then + printf 'No user-facing changes.\n' + fi + printf '\n**Full Changelog**: https://github.com/pingdotgg/t3code/compare/%s...%s\n' \ + "$previous_upstream_ref" "$UPSTREAM_REF" + } > release-notes.md + + - id: draft_release + name: Upload draft prerelease + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.prepare.outputs.tag }} + target_commitish: ${{ needs.prepare.outputs.ref }} + name: ${{ needs.prepare.outputs.release_name }} + body_path: release-notes.md + draft: true + prerelease: true + make_latest: false + files: release-assets/* + fail_on_unmatched_files: true + token: ${{ github.token }} + + - name: Publish complete prerelease + if: inputs.dry_run != true + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ steps.draft_release.outputs.id }} + run: | + set -euo pipefail + test -n "$RELEASE_ID" + gh api --method PATCH \ + "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + -F draft=false \ + -F prerelease=true \ + -f make_latest=false \ + >/dev/null + + - name: Promote successful nightly source + if: inputs.dry_run != true + shell: bash + run: | + git fetch origin '+refs/heads/nightly:refs/remotes/origin/nightly' || true + git push --force-with-lease origin HEAD:refs/heads/nightly + + - name: Promote verified stack to main + if: inputs.dry_run != true + shell: bash + env: + CANDIDATE_REF: ${{ needs.prepare.outputs.ref }} + MAIN_REF: ${{ needs.prepare.outputs.main_ref }} + SOURCE_REF: ${{ inputs.source_ref }} + run: | + set -euo pipefail + + # Everything here is about main's tip as prepare saw it: the commit + # promotion replaces, backs up, and leases against. The published + # stack itself is CANDIDATE_REF. + git fetch --no-tags origin "$MAIN_REF" + git rev-parse --verify "${MAIN_REF}^{commit}" >/dev/null + git rev-parse --verify "${CANDIDATE_REF}^{commit}" >/dev/null + + if [[ "$CANDIDATE_REF" == "$MAIN_REF" ]]; then + echo "main already matches the candidate; nothing to promote." + exit 0 + fi + + # A PR merged while this run was building moves main. On a + # scheduled run that is normal: skip, and the next candidate + # includes it. A source_ref run exists to put the resolution on + # main, so fail loudly instead (notify_failure fires). The release + # and nightly promotion above already landed and stand either way. + remote_main=$(git ls-remote origin refs/heads/main | cut -f1) + if [[ "$remote_main" != "$MAIN_REF" ]]; then + if [[ -n "${SOURCE_REF:-}" ]]; then + echo "main moved during this source_ref run; the release stands but main was not promoted. Refresh the resolution from current main and re-dispatch." >&2 + exit 1 + fi + echo "main moved during this run; skipping promotion — the next run's candidate will include it." + exit 0 + fi + + # The day's first promotion snapshots pre-promotion main; later + # promotions leave the snapshot alone. Same mechanics as prepare's + # "Align main to released nightly" step; keep the two in sync. + backup_ref="refs/heads/backup/main-$(date -u +%Y%m%d)" + if git ls-remote --exit-code origin "$backup_ref" >/dev/null; then + echo "${backup_ref#refs/heads/} already exists; leaving it in place." + else + status=$? + if [[ "$status" -ne 2 ]]; then + echo "Checking origin for ${backup_ref} failed (git ls-remote exit ${status})." + exit "$status" + fi + git push origin "${MAIN_REF}:${backup_ref}" + fi + + # The lease pins main to the commit this run started from. A failed + # lease means main moved in the seconds since the check above; fail + # loudly and let notify_failure fire rather than promote over it. + git push --force-with-lease="refs/heads/main:${MAIN_REF}" \ + origin "${CANDIDATE_REF}:refs/heads/main" + + - name: Update rolling fork features issue + if: inputs.dry_run != true && steps.fork_features.outputs.generated == 'true' + continue-on-error: true + env: + FORK_FEATURES_ISSUE_NUMBER: "43" + GH_TOKEN: ${{ github.token }} + shell: bash + run: gh issue edit "$FORK_FEATURES_ISSUE_NUMBER" --body-file fork-features.md + + notify_failure: + name: Notify Discord of failure + needs: [prepare, build_desktop, release] + if: >- + ${{ + always() && + ( + needs.prepare.result == 'failure' || + needs.prepare.result == 'cancelled' || + needs.build_desktop.result == 'failure' || + needs.build_desktop.result == 'cancelled' || + needs.release.result == 'failure' || + needs.release.result == 'cancelled' + ) + }} + runs-on: ubuntu-24.04 + permissions: {} + steps: + - uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645 # v7.0.0 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_CI }} + embed-title: Fork Nightly failed + embed-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + embed-description: | + `${{ github.sha }}` · `${{ github.actor }}` + + Prepare: `${{ needs.prepare.result }}` + Desktop builds: `${{ needs.build_desktop.result }}` + Release: `${{ needs.release.result }}` + embed-color: 15158332 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6abd702bf88..69a930645c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -666,7 +666,7 @@ jobs: name: Publish CLI to npm needs: [preflight, relay_public_config, build] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 12f35774799..da6d069f7c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## Verifying - Smallest proof that the change works. `vp test run ` for the tests you touched, targeted lint and typecheck for the scope you changed. +- `git diff --check` is a formatting sanity check, not a test or behavioral check. Do not cite it as proof that behavior works. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. - The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. @@ -111,13 +112,24 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## Pull requests +- This repository is the `yngatech/t3code` fork of `pingdotgg/t3code`. Treat the upstream repository as read-only: never open a PR against upstream unless the developer explicitly names upstream as the target and authorizes that contribution. A general request to open a PR authorizes a PR against the fork only. - Never make a PR unless the developer explicitly asks you to do so. - 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. +- When the pull request body is long, begin it with a `> [!NOTE]` callout containing a TL;DR. +- **Rebase standalone branches onto latest main before opening.** Stale branches conflict and burn a review round. +- **Rebasing after main was force-pushed.** This fork's `main` is a patch stack rebased onto upstream and force-pushed after each nightly rebase. Transplant only the branch's own commits: find the commit the branch was cut from (the parent of its first own commit — check `git log --oneline`; do not trust `git merge-base`, which resolves to a stale ancestor here), then `git rebase --onto origin/main `. - UI changes need before/after images. Motion or timing needs a short video. - 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. +### Stacked pull requests + +- Most changes are a single PR. Stack only when a large change splits into dependent, independently reviewable layers; unrelated concerns stay separate, non-stacked PRs. +- Manage a stack exclusively with the `gh stack` extension (`gh extension install github/gh-stack`). Keep it current with `gh stack rebase` then `gh stack push` — the stack's equivalent of the rebase-before-opening rule above. Plain `git rebase`, `git push --force`, or manual PR retargeting corrupt the stack's dependent branches and GitHub metadata. If the extension is unavailable, report that instead of falling back. +- `gh stack` is newer than most models' training data — run `gh stack --help` and review the workflow before your first stack operation rather than guessing from priors. +- `gh stack submit` and `gh stack merge` create or change remote PRs, so the existing rules apply: only when the developer explicitly asks. Note `gh stack merge ` also lands every unmerged layer below it — a middle layer cannot merge alone. Run `gh stack view` first and state exactly which layers will land. + ## 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/README.md b/README.md index 8ec101387f6..36daa6c6fbe 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). +> [!NOTE] +> This is the `yngatech/t3code` fork. See its [features and improvements](https://github.com/yngatech/t3code/issues/43) compared with upstream. + Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a34a55f16ac..9b6496644bd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,5 +35,5 @@ "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, - "productName": "T3 Code (Alpha)" + "productName": "T3 Code (yngatech Alpha)" } diff --git a/apps/desktop/resources/installer.nsh b/apps/desktop/resources/installer.nsh new file mode 100644 index 00000000000..deedc5e51d5 --- /dev/null +++ b/apps/desktop/resources/installer.nsh @@ -0,0 +1,8 @@ +!macro customInstall + # The separated install has completed and registered its new GUID. Remove the + # earlier fork's stale registration without running its uninstaller, because + # that old installation directory may still belong to upstream T3 Code. + # TODO(2026-10-08): Remove this migration after the two-month compatibility window. + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\48e3dbfe-4d90-524c-acdc-304cac9a97b1" + DeleteRegKey HKCU "Software\48e3dbfe-4d90-524c-acdc-304cac9a97b1" +!macroend diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 929afeeabe9..04169af5383 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -15,10 +15,11 @@ const repoRoot = NodePath.resolve(desktopDir, "..", ".."); const devBundleIdSuffix = NodePath.basename(repoRoot) .toLowerCase() .replaceAll(/[^a-z0-9]+/g, ""); -export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const appStageLabel = isDevelopment ? "Dev" : "Alpha"; +export const APP_DISPLAY_NAME = `T3 Code (yngatech ${appStageLabel})`; export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` - : "com.t3tools.t3code"; + : "dev.incognitojam.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; const LAUNCHER_VERSION = 14; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index de945054c89..65840a79e3e 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -194,8 +194,8 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); + assert.deepEqual(calls.setName, ["T3 Code (yngatech Alpha)"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (yngatech Alpha)"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); assert.deepEqual(calls.setDockIcon, ["/icon.png"]); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index eaf39018712..0f4e39655c5 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -107,7 +107,7 @@ function resolveDesktopAppBranding(input: { return { baseName: APP_BASE_NAME, stageLabel, - displayName: `${APP_BASE_NAME} (${stageLabel})`, + displayName: `${APP_BASE_NAME} (yngatech ${stageLabel})`, }; } @@ -225,7 +225,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => - isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + isDevelopment ? "com.t3tools.t3code.dev" : "dev.incognitojam.t3code", ), linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 23a75eb3f79..566c636f0be 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { confirmQuit: true, + completionSound: "none", confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], @@ -29,6 +30,7 @@ const clientSettings: ClientSettings = { fontSizePrompt: 14, fontSizeTerminal: 12, fontSmoothing: true, + githubStatusAlertsEnabled: false, glassOpacity: 80, planModeEnabled: false, providerModelPreferences: {}, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 32224c7a5ca..13b84f7960c 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -274,7 +274,7 @@ describe("DesktopUpdates", () => { }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("updates and broadcasts state from updater events", () => { + it.effect("downloads and broadcasts an available update immediately", () => { const harness = makeHarness(); return Effect.scoped( @@ -286,10 +286,10 @@ describe("DesktopUpdates", () => { yield* flushCallbacks; const state = yield* updates.getState; - assert.equal(state.status, "available"); + assert.equal(state.status, "downloading"); assert.equal(state.availableVersion, "1.2.4"); assert.isNotNull(state.checkedAt); - assert.equal(harness.sentStates.at(-1)?.status, "available"); + assert.equal(harness.sentStates.at(-1)?.status, "downloading"); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); @@ -321,7 +321,7 @@ describe("DesktopUpdates", () => { yield* flushCallbacks; const state = yield* updates.getState; - assert.equal(state.status, "available"); + assert.equal(state.status, "downloading"); assert.deepEqual(state.releaseNotes, [ { version: "1.2.4-nightly.20260709.766", @@ -407,7 +407,7 @@ describe("DesktopUpdates", () => { ); }); - it.effect("recovers download state after an unexpected setup failure", () => { + it.effect("leaves an automatic download available to retry after setup failure", () => { let disableDifferentialCalls = 0; const harness = makeHarness({ setDisableDifferentialDownload: Effect.suspend(() => { @@ -425,10 +425,6 @@ describe("DesktopUpdates", () => { harness.emit("update-available", { version: "1.2.4" }); yield* flushCallbacks; - const result = yield* updates.download; - assert.isTrue(result.accepted); - assert.isFalse(result.completed); - const failedState = yield* updates.getState; assert.equal(failedState.status, "available"); assert.equal(failedState.errorContext, "download"); @@ -451,6 +447,9 @@ describe("DesktopUpdates", () => { return Effect.void; } if (disableDifferentialCalls === 2) { + return Effect.die(new Error("automatic download setup failed")); + } + if (disableDifferentialCalls === 3) { return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never)); } return Effect.void; @@ -470,7 +469,10 @@ describe("DesktopUpdates", () => { const interruptedState = yield* updates.getState; assert.equal(interruptedState.status, "available"); - assert.isNull(interruptedState.message); + assert.equal( + interruptedState.message, + "Desktop update download action failed unexpectedly.", + ); const retry = yield* updates.download; assert.isTrue(retry.accepted); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 7357907e178..06070ceaa84 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -575,7 +575,8 @@ export const make = Effect.gen(function* () { const checkedAt = yield* currentIsoTimestamp; const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); - yield* setState( + yield* Ref.set( + updateStateRef, reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); @@ -583,6 +584,7 @@ export const make = Effect.gen(function* () { version: info.version, releaseNoteGroups: releaseNotes.length, }); + yield* downloadAvailableUpdate; }), ), Effect.catchCause((cause) => { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 9d6bbaea6bc..e18a6d4f468 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -57,6 +57,31 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); }); + it("keeps only curated highlights when the commit list follows them", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + "- Start threads from GitHub issues ([#14](https://example.com/pull/14))", + "- Show setup script outcomes ([t3code#12083](https://example.com/pull/12083))", + "", + "## What's Changed", + "", + "- fix(release): generate clearer nightly changelogs ([yngatech/t3code#59](https://example.com/pull/59)) by @cameron", + "", + "**Full Changelog**: https://example.com/compare/x...y", + ].join("\n"), + "1.2.3", + ); + expect(notes).toEqual([ + { + version: "1.2.3", + items: [ + "Start threads from GitHub issues (#14)", + "Show setup script outcomes (t3code#12083)", + ], + }, + ]); + }); + it("does not throw on out-of-range numeric entities and keeps the literal", () => { expect(() => normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 69857c92b3f..9d97380cb2a 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -71,15 +71,23 @@ function truncateReleaseNoteItem(item: string): string { return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; } -function isIgnoredReleaseNoteLine(line: string): boolean { - const normalized = line +function normalizeReleaseNoteLine(line: string): string { + return line .toLowerCase() .replace(/[*_`#]/g, "") .trim(); +} + +function isCommitListHeading(line: string): boolean { + const normalized = normalizeReleaseNoteLine(line); + return normalized === "what's changed" || normalized === "whats changed"; +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = normalizeReleaseNoteLine(line); return ( normalized === "" || - normalized === "what's changed" || - normalized === "whats changed" || + isCommitListHeading(line) || normalized === "full changelog" || normalized === "new contributors" || normalized.startsWith("compare: ") || @@ -97,6 +105,10 @@ function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray .replace(/^[-*]\s+/, "") .replace(/^\d+[.)]\s+/, "") .replace(/\s+/g, " "); + // Nightly release bodies open with curated highlights and follow with a raw + // "What's Changed" commit list; mixing the two styles reads poorly, so keep + // only the highlights. Bodies that open with the commit list still use it. + if (isCommitListHeading(item) && items.length > 0) break; if (isIgnoredReleaseNoteLine(item)) continue; items.push(truncateReleaseNoteItem(item)); if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 20bba1f6062..4b0f7c7fd76 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -29,6 +29,7 @@ import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRou import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; +import { GitDefaultRepositorySheet } from "./features/threads/git/GitDefaultRepositorySheet"; import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; @@ -340,6 +341,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "ConnectionsNew", "GitBranches", "GitCommit", + "GitDefaultRepository", "GitConfirm", "GitOverview", "NewTaskSheet", @@ -557,6 +559,15 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: true, }, }), + GitDefaultRepository: createNativeStackScreen({ + screen: GitDefaultRepositorySheet, + linking: `${THREAD_LINKING_PREFIX}/git/default-repository`, + options: { + presentation: "formSheet", + sheetAllowedDetents: [0.4, 0.7], + sheetGrabberVisible: true, + }, + }), GitConfirm: createNativeStackScreen({ screen: GitConfirmSheet, linking: `${THREAD_LINKING_PREFIX}/git-confirm`, diff --git a/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx b/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx index 04e2e236bea..01a95d73eec 100644 --- a/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx @@ -1,15 +1,24 @@ import type { StaticScreenProps } from "@react-navigation/native"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; + import { AddProjectDestinationScreen } from "./AddProjectScreen"; type AddProjectDestinationRouteParams = { readonly environmentId?: string | string[]; readonly source?: string | string[]; readonly remoteUrl?: string | string[]; + readonly repository?: string | string[]; readonly repositoryTitle?: string | string[]; + readonly parentRepository?: string | string[]; }; export function AddProjectDestinationRoute({ route, }: StaticScreenProps) { - return ; + return ( + <> + + + + ); } diff --git a/apps/mobile/src/features/projects/AddProjectLocalRoute.tsx b/apps/mobile/src/features/projects/AddProjectLocalRoute.tsx index abe7fb6d6c6..bd70be8f623 100644 --- a/apps/mobile/src/features/projects/AddProjectLocalRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectLocalRoute.tsx @@ -1,4 +1,6 @@ import type { StaticScreenProps } from "@react-navigation/native"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; + import { AddProjectLocalFolderScreen } from "./AddProjectScreen"; type AddProjectLocalRouteParams = { @@ -8,5 +10,10 @@ type AddProjectLocalRouteParams = { export function AddProjectLocalRoute({ route, }: StaticScreenProps) { - return ; + return ( + <> + + + + ); } diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 747a919a0c8..ec50bdf0c4b 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -5,10 +5,13 @@ import { buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, canCreateProjectInEnvironment, + repositoryOwnerAvatarUrl, findExistingAddProject, getAddProjectInitialQuery, + getCloneDestinationQuery, resolveAddProjectPath, sortAddProjectProviderSources, + type AddProjectRemoteProviderKind, type AddProjectRemoteSource, } from "@t3tools/client-runtime/operations/projects"; import { @@ -24,13 +27,19 @@ import { import { appendBrowsePathSegment, ensureBrowseDirectoryPath, + getBrowseDirectoryPath, inferProjectTitleFromPath, } from "@t3tools/client-runtime/state/projects"; -import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + CommandId, + type EnvironmentId, + ProjectId, + type SourceControlCloneDefaultRepository, +} from "@t3tools/contracts"; import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { ActivityIndicator, Alert, Image, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; import * as Cause from "effect/Cause"; @@ -194,6 +203,45 @@ function ListRow(props: { ); } +/** Owner avatar for a repository row, falling back to the provider mark. */ +function RepositoryOwnerAvatar(props: { + readonly nameWithOwner: string; + readonly remoteUrl: string | null; + readonly provider: AddProjectRemoteProviderKind; +}) { + const iconColor = useThemeColor("--color-icon"); + const [hasFailed, setHasFailed] = useState(false); + const avatarUrl = props.remoteUrl + ? repositoryOwnerAvatarUrl({ + repositoryUrl: props.remoteUrl, + nameWithOwner: props.nameWithOwner, + size: 96, + }) + : null; + + if (avatarUrl === null || hasFailed) { + return ; + } + + return ( + { + setHasFailed(true); + }} + /> + ); +} + +function SelectedCheckmark(props: { readonly selected: boolean }) { + const primaryColor = useThemeColor("--color-primary"); + if (!props.selected) { + return null; + } + return ; +} + function PrimaryActionButton(props: { readonly label: string; readonly disabled?: boolean; @@ -236,12 +284,24 @@ function ProjectPathInput(props: { ); } -function useBrowsePathInput(environment: EnvironmentOption | null) { +function useBrowsePathInput( + environment: EnvironmentOption | null, + cloneTarget?: { readonly nameWithOwner: string | null; readonly remoteUrl: string | null }, +) { const environmentId = environment?.environmentId ?? null; const environmentBaseDirectory = environment?.baseDirectory ?? null; - const [pathInput, commitPathInput] = useState(() => - getAddProjectInitialQuery(environmentBaseDirectory), + const cloneNameWithOwner = cloneTarget?.nameWithOwner ?? null; + const cloneRemoteUrl = cloneTarget?.remoteUrl ?? null; + const initialPathFor = useCallback( + (baseDirectory: string | null) => + getCloneDestinationQuery({ + parentPath: getAddProjectInitialQuery(baseDirectory), + nameWithOwner: cloneNameWithOwner, + remoteUrl: cloneRemoteUrl, + }), + [cloneNameWithOwner, cloneRemoteUrl], ); + const [pathInput, commitPathInput] = useState(() => initialPathFor(environmentBaseDirectory)); const previousEnvironmentIdRef = useRef(environmentId); const environmentRuntime = useRemoteEnvironmentRuntime(environmentId); const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { @@ -266,7 +326,8 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { await loadBrowsePath({ environmentId: environment.environmentId, - input: { partialPath: path }, + // The path can carry a destination name the browser never lists. + input: { partialPath: getBrowseDirectoryPath(path) }, }); } }, @@ -283,9 +344,9 @@ function useBrowsePathInput(environment: EnvironmentOption | null) { useEffect(() => { if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) { previousEnvironmentIdRef.current = environmentId; - setPathInput(getAddProjectInitialQuery(environmentBaseDirectory)); + setPathInput(initialPathFor(environmentBaseDirectory)); } - }, [environmentBaseDirectory, environmentId, setPathInput]); + }, [environmentBaseDirectory, environmentId, initialPathFor, setPathInput]); useEffect( () => () => { @@ -654,7 +715,11 @@ export function AddProjectRepositoryScreen(props: { environmentId: environment.environmentId, source, remoteUrl: repository.sshUrl, + repository: repository.nameWithOwner, repositoryTitle: repository.nameWithOwner, + ...(repository.parentNameWithOwner + ? { parentRepository: repository.parentNameWithOwner } + : {}), }), ); } @@ -699,11 +764,18 @@ function FolderBrowser(props: { readonly pathInput: string; readonly setPathInput: (path: string) => void; readonly navigateToBrowsePath: (path: string) => Promise; + readonly leafIsDestinationName?: boolean; }) { const accentColor = useThemeColor("--color-icon-muted"); const browsePath = useMemo( - () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), - [props.environment.platform, props.pathInput], + () => + getFilesystemBrowsePath( + props.pathInput, + props.environment.platform, + true, + props.leafIsDestinationName ?? false, + ), + [props.environment.platform, props.leafIsDestinationName, props.pathInput], ); const browseInput = useMemo( () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), @@ -747,7 +819,9 @@ function FolderBrowser(props: { right={null} onPress={() => { if (browsePath.parentPath) { - void props.navigateToBrowsePath(browsePath.parentPath); + void props.navigateToBrowsePath( + `${browsePath.parentPath}${browsePath.destinationName}`, + ); } }} /> @@ -764,7 +838,7 @@ function FolderBrowser(props: { browsePath.directoryPath.length > 0 ? appendBrowsePathSegment(browsePath.directoryPath, entry.name) : ensureBrowseDirectoryPath(entry.fullPath); - void props.navigateToBrowsePath(nextPath); + void props.navigateToBrowsePath(`${nextPath}${browsePath.destinationName}`); }} /> ))} @@ -834,8 +908,11 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st export function AddProjectDestinationScreen(props: { readonly environmentId?: string | string[]; + readonly source?: string | string[]; readonly remoteUrl?: string | string[]; + readonly repository?: string | string[]; readonly repositoryTitle?: string | string[]; + readonly parentRepository?: string | string[]; }) { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, @@ -843,9 +920,18 @@ export function AddProjectDestinationScreen(props: { const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); + const provider = addProjectRemoteSourceProvider(sourceFromParam(props.source)); + const repository = stringParam(props.repository); const repositoryTitle = stringParam(props.repositoryTitle); - const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = - useBrowsePathInput(environment); + const parentRepository = stringParam(props.parentRepository); + // Forks pick which repository `gh` targets, the way `gh repo set-default` + // does. The fork leads: it is the repository being cloned. + const [defaultRepository, setDefaultRepository] = + useState("cloned"); + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput( + environment, + { nameWithOwner: repositoryTitle, remoteUrl }, + ); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -862,10 +948,17 @@ export function AddProjectDestinationScreen(props: { return; } + // A fork is the only clone that needs its repository named on the server. + const isForkClone = provider !== null && repository !== null && parentRepository !== null; + setIsSubmitting(true); const cloneResult = await cloneRepository({ environmentId: environment.environmentId, input: { + // Only a fork needs naming: it is what lets the server wire up the + // upstream remote. Every other clone stays a plain URL clone, with no + // second repository lookup on the server. + ...(isForkClone ? { provider, repository, defaultRepository } : {}), remoteUrl, destinationPath: resolved.path, }, @@ -873,6 +966,14 @@ export function AddProjectDestinationScreen(props: { if (AsyncResult.isFailure(cloneResult)) { setError(errorMessage(Cause.squash(cloneResult.cause))); } else { + // The clone itself succeeded, so this is a warning rather than a failure: + // the repository is on disk, just without the remote that was asked for. + if (isForkClone && !cloneResult.value.upstream) { + Alert.alert( + "Upstream remote not added", + `Cloned, but ${parentRepository} could not be wired up as a remote.`, + ); + } const createResult = await createProject(cloneResult.value.cwd); if (createResult && AsyncResult.isFailure(createResult)) { setError(errorMessage(Cause.squash(createResult.cause))); @@ -882,11 +983,15 @@ export function AddProjectDestinationScreen(props: { }, [ cloneRepository, createProject, + defaultRepository, environment, isBrowseNavigating, isSubmitting, + parentRepository, pathInput, + provider, remoteUrl, + repository, ]); return ( @@ -896,10 +1001,47 @@ export function AddProjectDestinationScreen(props: { {repositoryTitle} - {remoteUrl} + {parentRepository ? `forked from ${parentRepository}` : remoteUrl} ) : null} + {parentRepository && repositoryTitle && provider ? ( + <> + Default repository + + Where pull requests, issues, and releases go + + + + } + right={} + onPress={() => setDefaultRepository("cloned")} + /> + + } + right={} + onPress={() => setDefaultRepository("parent")} + /> + + + ) : null} {environment ? ( <> ) : ( diff --git a/apps/mobile/src/features/threads/git/GitDefaultRepositorySheet.tsx b/apps/mobile/src/features/threads/git/GitDefaultRepositorySheet.tsx new file mode 100644 index 00000000000..aefef28c787 --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitDefaultRepositorySheet.tsx @@ -0,0 +1,174 @@ +import type { SourceControlDefaultRepositoryState } from "@t3tools/contracts"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useCallback, useEffect, useState } from "react"; +import { Platform, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AsyncResult } from "effect/unstable/reactivity"; +import * as Cause from "effect/Cause"; + +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; +import { SymbolView } from "../../../components/AppSymbol"; +import { AppText as Text } from "../../../components/AppText"; +import { ErrorBanner } from "../../../components/ErrorBanner"; +import { cn } from "../../../lib/cn"; +import { useThemeColor } from "../../../lib/useThemeColor"; +import { sourceControlEnvironment } from "../../../state/sourceControl"; +import { useAtomCommand } from "../../../state/use-atom-command"; +import { useAtomQueryRunner } from "../../../state/use-atom-query-runner"; +import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { useThreadSelection } from "../../../state/use-thread-selection"; + +type GitDefaultRepositorySheetProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}>; + +/** Sentinel row, matching the "Not set" option web offers. */ +const UNSET_ROW_KEY = "__unset__"; + +function remoteLabel(state: SourceControlDefaultRepositoryState, remoteName: string): string { + const remote = state.remotes.find((candidate) => candidate.remoteName === remoteName); + return remote?.nameWithOwner ?? remote?.url ?? remoteName; +} + +/** + * Mobile's half of Settings → Projects → Checkout → Default repository on web: + * which repository this checkout's pull requests, issues, and releases target. + * Same git config the GitHub CLI's `gh repo set-default` writes. + */ +export function GitDefaultRepositorySheet(_props: GitDefaultRepositorySheetProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const iconColor = useThemeColor("--color-icon"); + const primaryColor = useThemeColor("--color-primary"); + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + + const [state, setState] = useState(null); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const readDefaultRepository = useAtomQueryRunner(sourceControlEnvironment.defaultRepository, { + reportFailure: false, + }); + const writeDefaultRepository = useAtomCommand(sourceControlEnvironment.setDefaultRepository, { + reportFailure: false, + }); + + const environmentId = selectedThread?.environmentId ?? null; + useEffect(() => { + if (environmentId === null || selectedThreadCwd === null) return; + let cancelled = false; + void readDefaultRepository({ environmentId, input: { cwd: selectedThreadCwd } }).then( + (result) => { + if (cancelled) return; + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + } else { + setState(result.value); + } + }, + ); + return () => { + cancelled = true; + }; + }, [environmentId, readDefaultRepository, selectedThreadCwd]); + + const select = useCallback( + async (remoteName: string | null) => { + if (environmentId === null || selectedThreadCwd === null || isSaving) return; + setError(null); + setIsSaving(true); + const result = await writeDefaultRepository({ + environmentId, + input: { cwd: selectedThreadCwd, remoteName }, + }); + setIsSaving(false); + if (AsyncResult.isFailure(result)) { + setError(errorMessage(Cause.squash(result.cause))); + return; + } + setState(result.value); + navigation.goBack(); + }, + [environmentId, isSaving, navigation, selectedThreadCwd, writeDefaultRepository], + ); + + const rows = + state === null + ? [] + : [ + ...state.remotes.map((remote) => ({ + key: remote.remoteName, + title: + remote.remoteName === state.defaultRemoteName && state.defaultRepositoryPath + ? state.defaultRepositoryPath + : remoteLabel(state, remote.remoteName), + subtitle: remote.remoteName, + selected: remote.remoteName === state.defaultRemoteName, + })), + { + key: UNSET_ROW_KEY, + title: "Not set", + subtitle: "GitHub CLI decides", + selected: state.defaultRemoteName === null, + }, + ]; + + return ( + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + {error ? : null} + + Where pull requests, issues, and releases go for this checkout. + + + {rows.map((row, index) => ( + 0 && "border-t border-border-subtle", + )} + onPress={() => void select(row.key === UNSET_ROW_KEY ? null : row.key)} + > + + {row.title} + {row.subtitle} + + {row.selected ? ( + + ) : null} + + ))} + {state === null ? ( + + + Reading remotes… + + ) : null} + + + + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "An error occurred."; +} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 17e4de0ab6f..952068dd047 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -24,6 +24,7 @@ import { AppText as Text } from "../../../components/AppText"; import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; import { useEnvironmentQuery } from "../../../state/query"; +import { sourceControlEnvironment } from "../../../state/sourceControl"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; @@ -67,6 +68,30 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { : null, ); + // Only a checkout with something to choose between shows the row: a fork + // clone, or any repository whose remotes span more than one GitHub repo. + const defaultRepository = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? sourceControlEnvironment.defaultRepository({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); + const defaultRepositoryState = defaultRepository.data ?? null; + const canChooseDefaultRepository = + defaultRepositoryState !== null && + defaultRepositoryState.remotes.length > 1 && + defaultRepositoryState.remotes.some((remote) => remote.provider === "github"); + const defaultRepositoryLabel = !defaultRepositoryState + ? null + : (defaultRepositoryState.defaultRepositoryPath ?? + defaultRepositoryState.remotes.find( + (remote) => remote.remoteName === defaultRepositoryState.defaultRemoteName, + )?.nameWithOwner ?? + defaultRepositoryState.defaultRemoteName ?? + "Not set"); + const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; const currentStatusSummary = statusSummary(gitStatus.data); const currentWorktreePath = selectedThreadWorktreePath; @@ -271,6 +296,23 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ); }} /> + {canChooseDefaultRepository ? ( + <> + + + navigation.navigate("GitDefaultRepository", { + environmentId: String(environmentId), + threadId: String(threadId), + }) + } + /> + + ) : null} 0 || row.fileChangeStat.deletions > 0) + ? `+${row.fileChangeStat.additions} -${row.fileChangeStat.deletions}` + : null; + const fileChangeStat = statText ? row.fileChangeStat : undefined; + const displayText = [row.summary, row.detail, statText].filter(Boolean).join(" "); const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; return ( @@ -206,6 +212,16 @@ export function ThreadWorkLog(props: { {row.detail ? ( {row.detail} ) : null} + {fileChangeStat ? ( + + + {` +${fileChangeStat.additions}`} + + + {` -${fileChangeStat.deletions}`} + + + ) : null} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e1d46fd858e..2dbc6c7284f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -151,6 +151,110 @@ function makeThread( } describe("buildThreadFeed", () => { + it("shows projected file paths and line stats instead of serialized input", () => { + const thread = makeThread({ + id: ThreadId.make("thread-file-change"), + projectId: ProjectId.make("project-1"), + title: "File change preview", + worktreePath: "/workspace", + activities: [ + makeActivity({ + id: EventId.make("file-change-completed"), + kind: "tool.completed", + tone: "tool", + summary: "File change", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { + title: "File change", + itemType: "file_change", + detail: 'Edit: {"file_path":"/workspace/main.swift"}', + status: "completed", + data: { + files: [{ path: "/workspace/main.swift" }], + fileChangeStat: { additions: 2, deletions: 1 }, + }, + }, + }), + ], + }); + + const group = buildThreadFeed(thread)[0]; + expect(group).toMatchObject({ type: "activity-group" }); + if (!group || group.type !== "activity-group") return; + expect(group.activities[0]).toMatchObject({ + // The adapters' generic "File change" normalizes to one shared verb. + summary: "Edited file", + detail: "main.swift", + fileChangeStat: { additions: 2, deletions: 1 }, + }); + }); + + it("drops a generated worktree name from edited-file paths", () => { + const worktreePath = "/Users/cameron/.t3/worktrees/t3code-d9980d37"; + const filePath = `${worktreePath}/apps/web/src/dictation/dictationSession.ts`; + const thread = makeThread({ + id: ThreadId.make("thread-worktree-file-change"), + projectId: ProjectId.make("project-1"), + title: "Worktree file change preview", + worktreePath, + activities: [ + makeActivity({ + id: EventId.make("worktree-file-change-completed"), + kind: "tool.completed", + tone: "tool", + summary: "File change", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { + title: "File change", + itemType: "file_change", + status: "completed", + data: { files: [{ path: filePath }] }, + }, + }), + ], + }); + + const group = buildThreadFeed(thread)[0]; + expect(group).toMatchObject({ type: "activity-group" }); + if (!group || group.type !== "activity-group") return; + expect(group.activities[0]?.detail).toBe("apps/web/src/dictation/dictationSession.ts"); + expect(group.activities[0]?.getFullDetail()).toBe("apps/web/src/dictation/dictationSession.ts"); + }); + + it("renders command interactions as compact non-expandable rows", () => { + const thread = makeThread({ + id: ThreadId.make("thread-interaction"), + projectId: ProjectId.make("project-1"), + title: "Command interaction", + activities: [ + makeActivity({ + id: EventId.make("command-interaction"), + kind: "command.interaction", + summary: "Sent Ctrl+C", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { + interaction: "ctrl_c", + commandItemId: "exec-1", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread); + expect(feed).toMatchObject([ + { + type: "activity-group", + activities: [ + { + id: "command-interaction", + summary: "Sent Ctrl+C", + canExpand: false, + }, + ], + }, + ]); + }); + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), @@ -274,6 +378,90 @@ describe("buildThreadFeed", () => { ); }); + it("collapses a setup run into its latest state across interleaved activity", () => { + const thread = makeThread({ + id: ThreadId.make("thread-setup-collapse"), + projectId: ProjectId.make("project-1"), + title: "Setup lifecycle", + activities: [ + makeActivity({ + id: EventId.make("setup-requested"), + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { runId: "setup-run-1" }, + }), + makeActivity({ + id: EventId.make("unrelated-work"), + kind: "runtime.info", + summary: "Created worktree", + createdAt: "2026-04-01T00:00:02.000Z", + }), + makeActivity({ + id: EventId.make("setup-started"), + kind: "setup-script.started", + summary: "Setup script started", + createdAt: "2026-04-01T00:00:03.000Z", + payload: { runId: "setup-run-1" }, + }), + makeActivity({ + id: EventId.make("setup-failed"), + kind: "setup-script.failed", + tone: "error", + summary: "Setup script failed", + createdAt: "2026-04-01T00:00:04.000Z", + payload: { runId: "setup-run-1", exitCode: 1 }, + }), + ], + }); + + const activities = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activities).toHaveLength(2); + expect(activities[0]).toMatchObject({ + id: "setup-requested", + createdAt: "2026-04-01T00:00:01.000Z", + summary: "Setup script failed", + status: "failure", + }); + expect(activities[1]?.summary).toBe("Created worktree"); + }); + + it("keeps separate setup runs and preserves completed labels", () => { + const thread = makeThread({ + id: ThreadId.make("thread-separate-setup-runs"), + projectId: ProjectId.make("project-1"), + title: "Separate setup runs", + activities: [ + makeActivity({ + id: EventId.make("setup-one"), + kind: "setup-script.completed", + summary: "Setup script completed", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { runId: "setup-run-1" }, + }), + makeActivity({ + id: EventId.make("setup-two"), + kind: "setup-script.completed", + summary: "Setup script completed", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { runId: "setup-run-2" }, + }), + ], + }); + + const activities = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activities.map((activity) => activity.summary)).toEqual([ + "Setup script completed", + "Setup script completed", + ]); + }); + it("keeps MCP inputs available to expanded mobile work rows", () => { const turnId = TurnId.make("turn-mcp"); const thread = makeThread({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2..3da9f2b8991 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -8,6 +8,15 @@ import type { UserInputQuestion, } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import { formatWorkspaceRelativePath } from "@t3tools/shared/filePathDisplay"; +import { + deriveToolFileChangeLineStat, + type ToolFileChangeLineStat, +} from "@t3tools/shared/toolActivity"; +import { + deriveToolRowPresentation, + type ToolRowArgument, +} from "@t3tools/shared/toolRowPresentation"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -36,6 +45,7 @@ export interface ThreadFeedActivity { readonly turnId: TurnId | null; readonly summary: string; readonly detail: string | null; + readonly fileChangeStat?: ToolFileChangeLineStat; readonly canExpand: boolean; readonly getFullDetail: () => string | null; readonly getCopyText: () => string; @@ -69,8 +79,13 @@ interface WorkLogEntry { command?: string; rawCommand?: string; changedFiles?: ReadonlyArray; + fileChangeStat?: ToolFileChangeLineStat; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; + /** Display-only provider tool name; never part of the collapse key. */ + toolName?: string; + /** Whitelisted, length-capped tool arguments from the server projection. */ + toolInput?: Record; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; @@ -80,6 +95,7 @@ interface WorkLogEntry { interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; + setupRunId?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; } @@ -349,8 +365,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) : null; + const itemType = extractWorkLogItemType(payload); const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); + const fileChangeStat = + itemType === "file_change" ? deriveToolFileChangeLineStat(payload?.data) : undefined; const title = extractToolTitle(payload); // task.updated included: terminal bypassed updates (Codex children's only // terminal signal) must carry task identity so they collapse per child @@ -389,7 +408,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo : activity.tone, activityKind: activity.kind, }; - const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); if ( !taskDetailAsLabel && @@ -411,9 +429,20 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (changedFiles.length > 0) { entry.changedFiles = changedFiles; } + if (fileChangeStat) { + entry.fileChangeStat = fileChangeStat; + } if (title) { entry.toolTitle = title; } + const toolName = asTrimmedString(asRecord(payload?.data)?.toolName); + if (toolName) { + entry.toolName = toolName; + } + const toolInput = asRecord(asRecord(payload?.data)?.input); + if (toolInput) { + entry.toolInput = toolInput; + } if (itemType === "mcp_tool_call") { const data = asRecord(payload?.data); if (data?.item !== undefined) { @@ -426,6 +455,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } + if (activity.kind.startsWith("setup-script.") && typeof payload?.runId === "string") { + entry.setupRunId = payload.runId; + } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; @@ -444,10 +476,28 @@ function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; + const setupRowIndex = new Map(); // Subagent rows collapse by identity, not adjacency (quiet-timeline // guarantee; mirrors web's session-logic). const taskRowIndex = new Map(); for (const entry of entries) { + if (entry.setupRunId !== undefined) { + const existingIndex = setupRowIndex.get(entry.setupRunId); + if (existingIndex !== undefined) { + const existing = collapsed[existingIndex]!; + collapsed[existingIndex] = { + ...mergeDerivedWorkLogEntries(existing, entry), + id: existing.id, + createdAt: existing.createdAt, + turnId: existing.turnId, + setupRunId: entry.setupRunId, + }; + continue; + } + setupRowIndex.set(entry.setupRunId, collapsed.length); + collapsed.push(entry); + continue; + } const isTaskRow = entry.taskId !== undefined && (entry.activityKind === "task.progress" || @@ -494,15 +544,20 @@ function mergeDerivedWorkLogEntries( next: DerivedWorkLogEntry, ): DerivedWorkLogEntry { const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); + const fileChangeStat = next.fileChangeStat ?? previous.fileChangeStat; const detail = next.detail ?? previous.detail; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; const toolTitle = next.toolTitle ?? previous.toolTitle; + // Claude stamps `toolName` on every update row but only some completions. + const toolName = next.toolName ?? previous.toolName; + const toolInput = next.toolInput ?? previous.toolInput; const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolData = next.toolData ?? previous.toolData; + const setupRunId = next.setupRunId ?? previous.setupRunId; return { ...previous, ...next, @@ -510,12 +565,16 @@ function mergeDerivedWorkLogEntries( ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), + ...(fileChangeStat ? { fileChangeStat } : {}), ...(toolTitle ? { toolTitle } : {}), + ...(toolName ? { toolName } : {}), + ...(toolInput ? { toolInput } : {}), ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), ...(toolData !== undefined ? { toolData } : {}), + ...(setupRunId !== undefined ? { setupRunId } : {}), }; } @@ -646,7 +705,14 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { return "zap"; } -function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { +function formatToolFilePath(path: string, workspaceRoot: string | undefined): string { + return formatWorkspaceRelativePath(path, workspaceRoot, { includeWorkspaceLabel: false }); +} + +function buildWorkEntryExpandedBody( + entry: WorkLogEntry, + workspaceRoot: string | undefined, +): string | null { const blocks: string[] = []; const appendUniqueBlock = (value: string | null | undefined) => { const trimmed = value?.trim(); @@ -661,7 +727,9 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { appendUniqueBlock(entry.rawCommand ?? entry.command); appendUniqueBlock(entry.detail); if ((entry.changedFiles?.length ?? 0) > 0) { - appendUniqueBlock(entry.changedFiles!.join("\n")); + appendUniqueBlock( + entry.changedFiles!.map((path) => formatToolFilePath(path, workspaceRoot)).join("\n"), + ); } return blocks.length > 0 ? blocks.join("\n\n") : null; @@ -688,17 +756,54 @@ function memoizeValue(build: () => T): () => T { }; } +/** Shared with web and the agents panel; see `toolRowPresentation`. */ +function toolRowPresentationFor(workEntry: DerivedWorkLogEntry) { + if (workEntry.activityKind.startsWith("setup-script.")) { + return undefined; + } + return deriveToolRowPresentation({ + toolName: workEntry.toolName, + itemType: workEntry.itemType, + label: workEntry.toolTitle ?? workEntry.label, + detail: workEntry.detail, + input: workEntry.toolInput, + command: workEntry.command, + changedFiles: workEntry.changedFiles, + }); +} + +function formatToolRowArgument( + argument: ToolRowArgument, + workspaceRoot: string | undefined, +): string { + if (argument.kind !== "path") { + return argument.value; + } + const displayPath = formatToolFilePath(argument.value, workspaceRoot); + return argument.moreCount ? `${displayPath} +${argument.moreCount} more` : displayPath; +} + function workEntryPreview( - workEntry: Pick, + workEntry: Pick, + workspaceRoot: string | undefined, ): string | null { if (workEntry.command) return workEntry.command; + if (workEntry.itemType === "file_change" && (workEntry.changedFiles?.length ?? 0) > 0) { + const [firstPath] = workEntry.changedFiles ?? []; + if (!firstPath) return null; + const displayPath = formatToolFilePath(firstPath, workspaceRoot); + return workEntry.changedFiles!.length === 1 + ? displayPath + : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; + } if (workEntry.detail) return workEntry.detail; if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; if (!firstPath) return null; + const displayPath = formatToolFilePath(firstPath, workspaceRoot); return workEntry.changedFiles!.length === 1 - ? firstPath - : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; + ? displayPath + : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } function capitalizePhrase(value: string): string { @@ -709,7 +814,14 @@ function capitalizePhrase(value: string): string { return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; } -function workEntryHeading(workEntry: WorkLogEntry): string { +function workEntryHeading(workEntry: DerivedWorkLogEntry): string { + if (workEntry.activityKind.startsWith("setup-script.")) { + return capitalizePhrase(workEntry.label); + } + const presentation = toolRowPresentationFor(workEntry); + if (presentation) { + return presentation.heading; + } if (!workEntry.toolTitle) { return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); } @@ -1517,6 +1629,7 @@ export function buildThreadFeed( readonly loadedMessages?: ReadonlyArray; }, ): ThreadFeedEntry[] { + const workspaceRoot = thread.worktreePath ?? undefined; const loadedMessages = options?.loadedMessages ?? thread.messages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; @@ -1540,8 +1653,16 @@ export function buildThreadFeed( }) .map((entry) => { const summary = workEntryHeading(entry); - const detail = workEntryPreview(entry); - const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); + // As on web: a presentation owns its argument, including its absence. + const presentation = toolRowPresentationFor(entry); + const detail = presentation + ? presentation.argument + ? formatToolRowArgument(presentation.argument, workspaceRoot) + : null + : workEntryPreview(entry, workspaceRoot); + const getFullDetail = memoizeValue(() => + buildWorkEntryExpandedBody(entry, workspaceRoot), + ); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] .filter((value, index, values): value is string => { @@ -1560,6 +1681,7 @@ export function buildThreadFeed( turnId: entry.turnId, summary, detail, + ...(entry.fileChangeStat ? { fileChangeStat: entry.fileChangeStat } : {}), canExpand: workEntryHasExpandedBody(entry), getFullDetail, getCopyText, diff --git a/apps/mobile/src/state/composer-draft-sync.ts b/apps/mobile/src/state/composer-draft-sync.ts new file mode 100644 index 00000000000..2a95a61b1a9 --- /dev/null +++ b/apps/mobile/src/state/composer-draft-sync.ts @@ -0,0 +1,147 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + canonicalComposerDraftCommon, + composerDraftCommonEquals, + createComposerDraftEnvironmentAtoms, + createComposerDraftSyncController, + type ComposerDraftSyncController, +} from "@t3tools/client-runtime/state/composer-drafts"; +import type { + ComposerDraftCommon, + ComposerDraftSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef } from "react"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { scopedThreadKey } from "../lib/scopedEntities"; +import { uuidv4 } from "../lib/uuid"; +import { appAtomRegistry } from "./atom-registry"; +import { serverEnvironment } from "./server"; +import { + applySyncedComposerDraftCommon, + composerDraftsAtom, + composerDraftsLoadedAtom, + type ComposerDraft, +} from "./use-composer-drafts"; + +export const composerDraftEnvironment = createComposerDraftEnvironmentAtoms(connectionAtomRuntime); + +const EMPTY_SYNC_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile:composer-draft-sync-disabled"), +); +const revisions = new Map(); +const suppressedPostSendCommon = new Map(); + +export function readComposerDraftRevision(threadRef: ScopedThreadRef): number | undefined { + return revisions.get(scopedThreadKey(threadRef.environmentId, threadRef.threadId)); +} + +/** Prevents retained selector settings from being mistaken for a new draft. */ +export function markComposerDraftSent(threadRef: ScopedThreadRef): void { + const key = scopedThreadKey(threadRef.environmentId, threadRef.threadId); + const draft = appAtomRegistry.get(composerDraftsAtom)[key]; + suppressedPostSendCommon.set(key, commonFromDraft(draft ?? { text: "", attachments: [] })); +} + +function commonFromDraft(draft: ComposerDraft): ComposerDraftCommon | null { + if (draft.attachments.length > 0) return null; + return canonicalComposerDraftCommon({ + text: draft.text, + modelSelection: draft.modelSelection ?? null, + runtimeMode: draft.runtimeMode ?? null, + interactionMode: draft.interactionMode ?? null, + }); +} + +export function useServerComposerDraftSync(threadRef: ScopedThreadRef | null): void { + const drafts = useAtomValue(composerDraftsAtom); + const draftsLoaded = useAtomValue(composerDraftsLoadedAtom); + const serverConfig = useAtomValue( + threadRef === null + ? EMPTY_SYNC_ATOM + : serverEnvironment.configValueAtom(threadRef.environmentId), + ); + const enabled = + draftsLoaded && + threadRef !== null && + serverConfig !== null && + "environment" in serverConfig && + serverConfig.environment.capabilities.composerDraftSync === true; + const streamResult = useAtomValue( + enabled && threadRef !== null + ? composerDraftEnvironment.changes({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }) + : EMPTY_SYNC_ATOM, + ); + const draftKey = + threadRef === null ? null : scopedThreadKey(threadRef.environmentId, threadRef.threadId); + const draft = draftKey === null ? null : (drafts[draftKey] ?? null); + const draftRef = useRef(draft); + draftRef.current = draft; + const controllerRef = useRef(null); + + useEffect(() => { + controllerRef.current?.dispose(); + if (!enabled || threadRef === null || draftKey === null) { + controllerRef.current = null; + return; + } + const key = draftKey; + const readLocal = () => { + const common = commonFromDraft(draftRef.current ?? { text: "", attachments: [] }); + if (!suppressedPostSendCommon.has(key)) return common; + const baseline = suppressedPostSendCommon.get(key) ?? null; + if (composerDraftCommonEquals(common, baseline)) return null; + suppressedPostSendCommon.delete(key); + return common; + }; + const controller = createComposerDraftSyncController({ + threadId: threadRef.threadId, + readLocal, + canApplyRemote: () => (draftRef.current?.attachments.length ?? 0) === 0, + applyRemote: (common) => applySyncedComposerDraftCommon(key, common), + update: async (input) => { + const result = await composerDraftEnvironment.update.run(appAtomRegistry, { + environmentId: threadRef.environmentId, + input, + }); + return AsyncResult.isSuccess(result) ? result.value : null; + }, + createMutationId: () => `mobile:${uuidv4()}`, + scheduleTask: (task, delayMs) => { + const timer = setTimeout(task, delayMs); + return () => clearTimeout(timer); + }, + onRevisionChange: (snapshot: ComposerDraftSnapshot) => { + revisions.set(key, snapshot.revision); + }, + }); + controllerRef.current = controller; + return () => { + controller.dispose(); + if (controllerRef.current === controller) controllerRef.current = null; + revisions.delete(key); + suppressedPostSendCommon.delete(key); + }; + }, [draftKey, enabled, threadRef?.environmentId, threadRef?.threadId]); + + useEffect(() => { + if (streamResult !== null && AsyncResult.isSuccess(streamResult)) { + controllerRef.current?.observeSnapshot(streamResult.value); + } + }, [streamResult]); + + useEffect(() => { + controllerRef.current?.observeLocalChange(); + }, [ + draft?.attachments, + draft?.interactionMode, + draft?.modelSelection, + draft?.runtimeMode, + draft?.text, + ]); +} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index eede506976a..69af8ccc494 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -6,6 +6,7 @@ import { IsoDateTime, MessageId, ModelSelection, + NonNegativeInt, ProjectId, ProviderInteractionMode, RuntimeMode, @@ -21,7 +22,7 @@ import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -const THREAD_OUTBOX_SCHEMA_VERSION = 3; +const THREAD_OUTBOX_SCHEMA_VERSION = 4; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; const QueuedThreadCreationSchema = Schema.Struct({ @@ -37,7 +38,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ }); export const QueuedThreadMessageSchema = Schema.Struct({ - schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]), + schemaVersion: Schema.Literals([1, 2, 3, THREAD_OUTBOX_SCHEMA_VERSION]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, @@ -47,6 +48,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), + composerDraftRevision: Schema.optional(NonNegativeInt), // Present when the queued item creates a brand-new thread (pending task) // instead of appending a turn to an existing one. creation: Schema.optional(QueuedThreadCreationSchema), @@ -76,6 +78,7 @@ export interface QueuedThreadMessage { readonly modelSelection?: ModelSelectionType; readonly runtimeMode?: RuntimeModeType; readonly interactionMode?: ProviderInteractionModeType; + readonly composerDraftRevision?: number; readonly creation?: QueuedThreadCreation; readonly createdAt: string; } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index b12ad2dc584..b31fba3f622 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -58,18 +58,20 @@ describe("thread outbox", () => { }); }); - it("decodes the persisted schema and rejects incomplete messages", () => { + it("decodes persisted v1-v3 messages and rejects incomplete messages", () => { const message = queuedMessage({ messageId: "message-1", createdAt: "2026-06-08T10:00:01.000Z", }); - expect( - decodeQueuedThreadMessage({ - schemaVersion: 1, - ...message, - }), - ).toEqual(message); + for (const schemaVersion of [1, 2, 3] as const) { + expect( + decodeQueuedThreadMessage({ + schemaVersion, + ...message, + }), + ).toEqual(message); + } expect(() => decodeQueuedThreadMessage({ schemaVersion: 1, @@ -92,6 +94,7 @@ describe("thread outbox", () => { }, runtimeMode: "approval-required", interactionMode: "plan", + composerDraftRevision: 7, } satisfies QueuedThreadMessage; expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual( diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 8dbddfe1fec..adf76ca88c8 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -61,6 +61,7 @@ vi.mock("expo-file-system", () => ({ import { appAtomRegistry } from "./atom-registry"; import { + applySyncedComposerDraftCommon, clearComposerDraftContentState, ComposerDraftPersistenceError, composerDraftsAtom, @@ -86,6 +87,42 @@ afterEach(() => { }); describe("mobile composer drafts", () => { + it("applies synchronized common state without replacing local attachments or workspace", () => { + const draftKey = "environment-1:thread-1"; + const attachment = { + id: "image-1", + previewUri: "file:///image.png", + type: "image" as const, + name: "image.png", + mimeType: "image/png", + sizeBytes: 4, + dataUrl: "data:image/png;base64,AAAA", + }; + const workspaceSelection = { + mode: "worktree" as const, + branch: "main", + worktreePath: "/repo-worktree", + }; + appAtomRegistry.set(composerDraftsAtom, { + [draftKey]: { text: "local", attachments: [attachment], workspaceSelection }, + }); + + applySyncedComposerDraftCommon(draftKey, { + text: "remote", + modelSelection: null, + runtimeMode: "full-access", + interactionMode: "plan", + }); + + expect(getComposerDraftSnapshot(draftKey)).toEqual({ + text: "remote", + attachments: [attachment], + workspaceSelection, + runtimeMode: "full-access", + interactionMode: "plan", + }); + }); + it("hydrates selector state even when the message content is empty", () => { expect( decodePersistedComposerDrafts({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7dbea23596c..8a33690c216 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,5 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { + type ComposerDraftCommon, ModelSelection as ModelSelectionSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, @@ -101,6 +102,10 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.keepAlive, Atom.withLabel("mobile:composer-drafts"), ); +export const composerDraftsLoadedAtom = Atom.make(false).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:composer-drafts-loaded"), +); let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; @@ -265,6 +270,9 @@ export function ensureComposerDraftsLoaded(): void { }), ); // Draft loading is best-effort; in-memory drafts still keep working. + }) + .finally(() => { + appAtomRegistry.set(composerDraftsLoadedAtom, true); }); } @@ -391,6 +399,41 @@ export function updateComposerDraftSettings( }); } +/** Applies the server-owned draft section without touching device-local assets. */ +export function applySyncedComposerDraftCommon( + draftKey: string, + common: ComposerDraftCommon | null, +): void { + updateComposerDrafts((current) => { + const existing = normalizeDraft(current[draftKey]); + const { + modelSelection: _modelSelection, + runtimeMode: _runtimeMode, + interactionMode: _interactionMode, + ...deviceLocal + } = existing; + const draft: ComposerDraft = { + ...deviceLocal, + text: common?.text ?? "", + ...(common?.modelSelection === null || common?.modelSelection === undefined + ? {} + : { modelSelection: common.modelSelection }), + ...(common?.runtimeMode === null || common?.runtimeMode === undefined + ? {} + : { runtimeMode: common.runtimeMode }), + ...(common?.interactionMode === null || common?.interactionMode === undefined + ? {} + : { interactionMode: common.interactionMode }), + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { ...current, [draftKey]: draft }; + }); +} + export function clearComposerDraftContentState( current: Record, draftKey: string, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 721c82a0e38..f7af631a63e 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -11,6 +11,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; @@ -41,6 +42,11 @@ 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 { + markComposerDraftSent, + readComposerDraftRevision, + useServerComposerDraftSync, +} from "./composer-draft-sync"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -86,6 +92,14 @@ export function useThreadComposerState() { const selectedThreadKey = selectedThreadShell ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) : null; + const selectedThreadRef = useMemo( + () => + selectedThreadShell + ? scopeThreadRef(selectedThreadShell.environmentId, selectedThreadShell.id) + : null, + [selectedThreadShell], + ); + useServerComposerDraftSync(selectedThreadRef); const selectedThreadQueuedMessages = useMemo( () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], @@ -145,6 +159,8 @@ export function useThreadComposerState() { const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); + const composerDraftRevision = + selectedThreadRef === null ? undefined : readComposerDraftRevision(selectedThreadRef); // Enqueue publishes the queued atom synchronously (the durable write // happens behind it), so clearing the draft here gives send feedback on // the tap frame instead of after file I/O. If the write fails the message @@ -160,9 +176,11 @@ export function useThreadComposerState() { modelSelection: draft.modelSelection ?? thread.modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, interactionMode: draft.interactionMode ?? thread.interactionMode, + ...(composerDraftRevision === undefined ? {} : { composerDraftRevision }), createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); + if (selectedThreadRef !== null) markComposerDraftSent(selectedThreadRef); enqueuePromise.catch((error: unknown) => { // Restore text via merge (idempotent) but attachments via the uncapped // append: the merge path slots existing attachments first and truncates @@ -175,7 +193,7 @@ export function useThreadComposerState() { ); }); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [selectedThreadDetail, selectedThreadRef, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e..08c2a792f1f 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -231,6 +231,9 @@ export function useThreadOutboxDrain(): void { modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, interactionMode: settings.interactionMode, + ...(queuedMessage.composerDraftRevision === undefined + ? {} + : { composerDraftRevision: queuedMessage.composerDraftRevision }), createdAt: queuedMessage.createdAt, }, }); diff --git a/apps/server/integration/workspacePortEnvironment.integration.test.ts b/apps/server/integration/workspacePortEnvironment.integration.test.ts new file mode 100644 index 00000000000..09e9d820173 --- /dev/null +++ b/apps/server/integration/workspacePortEnvironment.integration.test.ts @@ -0,0 +1,84 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import type { TerminalEvent } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +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 Ref from "effect/Ref"; + +import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; +import * as ProcessRunner from "../src/processRunner.ts"; +import * as TerminalManager from "../src/terminal/Manager.ts"; +import * as NodePtyAdapter from "../src/terminal/NodePtyAdapter.ts"; +import * as WorkspacePortAllocator from "../src/workspace/WorkspacePortAllocator.ts"; + +const integrationLayer = it.layer( + Layer.mergeAll( + NodeServices.layer, + SqlitePersistenceMemory, + ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), + ), +); + +integrationLayer("workspace port environment integration", (it) => { + it.effect("an actual terminal child sees the same port loaded by a new allocator", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const workspacePath = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-workspace-port-proof-", + }); + + const firstAllocator = yield* WorkspacePortAllocator.make(); + const allocatedPort = yield* firstAllocator.getBasePort(workspacePath); + const reloadedAllocator = yield* WorkspacePortAllocator.make(); + const persistedPort = yield* reloadedAllocator.getBasePort(workspacePath); + assert.strictEqual(persistedPort, allocatedPort); + + const ptyAdapter = yield* NodePtyAdapter.make(); + const terminalManager = yield* TerminalManager.makeWithOptions({ + logsDir: path.join(workspacePath, ".terminal-logs"), + ptyAdapter, + shellResolver: () => (platform === "win32" ? "powershell.exe" : "/bin/sh"), + resolveWorkspaceEnvironment: reloadedAllocator.environmentFor, + }); + const output = yield* Ref.make(""); + const completed = yield* Deferred.make(); + const unsubscribe = yield* terminalManager.subscribe((event) => + event.type === "output" + ? Ref.update(output, (current) => current + event.data) + : event.type === "exited" || event.type === "error" + ? Deferred.succeed(completed, event).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* terminalManager.openCommand({ + threadId: "workspace-port-proof-thread", + terminalId: "workspace-port-proof-terminal", + cwd: workspacePath, + worktreePath: workspacePath, + command: + "node -e \"process.stdout.write('PORT_PROOF=' + process.env.T3CODE_WORKSPACE_PORT)\"", + }); + + const completion = yield* Deferred.await(completed); + const observedOutput = yield* Ref.get(output); + assert.strictEqual(completion.type, "exited"); + if (completion.type === "exited") assert.strictEqual(completion.exitCode, 0); + assert.include(observedOutput, `PORT_PROOF=${allocatedPort}`); + + if (process.env.T3CODE_PRINT_WORKSPACE_PORT_PROOF === "1") { + yield* Effect.sync(() => + process.stdout.write( + `workspace port proof: allocated=${allocatedPort} persisted=${persistedPort} child=${observedOutput.trim()}\n`, + ), + ); + } + }), + ); +}); diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index ddc5b7d57f8..7170cb06c06 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import ForkMigration0001 from "../src/persistence/ForkMigrations/001_ComposerDrafts.ts"; import { runMigrations } from "../src/persistence/Migrations.ts"; import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; import { runMigrateDevDb } from "./migrate-dev-db.ts"; @@ -18,6 +19,7 @@ const withDatabase = ( * `stopped-thread` qualifies for the clone. */ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* ( baseDir: string, + migrationState: "current" | "legacy-fork" = "current", ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -28,7 +30,16 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio databasePath, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations(); + if (migrationState === "legacy-fork") { + yield* runMigrations({ toMigrationInclusive: 38 }); + yield* ForkMigration0001; + yield* sql` + INSERT INTO effect_sql_migrations (migration_id, name) + VALUES (39, 'ComposerDrafts') + `; + } else { + yield* runMigrations(); + } // The real shared db carries this column from a branch build without a // matching migration; reproduce that drift so the filter is exercised. yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`; @@ -132,6 +143,91 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); + it.effect("repairs legacy fork migration history before pruning", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-legacy-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ + prefix: "migrate-dev-db-legacy-dest-", + }); + const source = yield* createFixtureSource(sourceDir, "legacy-fork"); + yield* withDatabase( + source, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO composer_drafts ( + thread_id, + revision, + common_json, + updated_at, + client_mutation_id + ) VALUES + ('stopped-thread', 1, '{"text":"keep"}', '2026-08-09', 'kept-draft'), + ('running-thread', 1, '{"text":"discard"}', '2026-08-09', 'discarded-draft') + `; + }), + ); + + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ); + + assert.include(result.executedMigrations, "40_ProjectionProjectFaviconPath"); + const migrated = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const projectColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + const upstreamHistory = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id >= 39 + ORDER BY migration_id + `; + const forkHistory = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM yngatech_sql_migrations + `; + const drafts = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id + FROM composer_drafts + ORDER BY thread_id + `; + return { projectColumns, upstreamHistory, forkHistory, drafts }; + }), + ); + assert.includeMembers( + migrated.projectColumns.map(({ name }) => name), + ["default_thread_env_mode", "favicon_path"], + ); + assert.deepStrictEqual(migrated.upstreamHistory, [ + { + migration_id: 39, + name: "ProjectionProjectsDefaultThreadEnvMode", + }, + { + migration_id: 40, + name: "ProjectionProjectFaviconPath", + }, + ]); + assert.deepStrictEqual(migrated.forkHistory, [ + { migration_id: 1, name: "ComposerDrafts" }, + { migration_id: 2, name: "WorkspacePortAllocations" }, + ]); + assert.deepStrictEqual(migrated.drafts, [{ thread_id: "stopped-thread" }]); + }), + ); + it.effect("refuses while a dev server holds the destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 0958f2149f4..4c6be95108e 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -38,7 +38,8 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Command, Flag } from "effect/unstable/cli"; -import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; +import { forkMigrationManifest, runAllMigrations } from "../src/persistence/ForkMigrations.ts"; +import { migrationManifest } from "../src/persistence/Migrations.ts"; import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass()( @@ -301,6 +302,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra "projection_pending_approvals", "projection_thread_proposed_plans", "checkpoint_diff_blobs", + "composer_drafts", ]) { yield* sql.unsafe( `DELETE FROM ${table} WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`, @@ -347,6 +349,16 @@ const verifyMigrationSlots = Effect.fn("verifyMigrationSlots")(function* () { return yield* new MigrateDevDbSlotCollisionError({ slot, codeName, appliedName }); } } + + const appliedFork = yield* sql<{ migration_id: number; name: string }>` + SELECT migration_id, name FROM yngatech_sql_migrations`; + const appliedForkById = new Map(appliedFork.map((row) => [Number(row.migration_id), row.name])); + for (const [slot, codeName] of forkMigrationManifest) { + const appliedName = appliedForkById.get(slot); + if (appliedName !== undefined && appliedName !== codeName) { + return yield* new MigrateDevDbSlotCollisionError({ slot, codeName, appliedName }); + } + } }); export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( @@ -436,7 +448,11 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( const sql = yield* SqlClient.SqlClient; // Mirror server boot (persistence/Layers/Sqlite.ts). yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared; - return yield* runMigrations(); + const result = yield* runAllMigrations(); + return [ + ...result.upstream.map(([id, name]) => `${id}_${name}`), + ...result.fork.map(([id, name]) => `fork:${id}_${name}`), + ]; }).pipe( Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), wrapPhase("migrate", snapshotPath), @@ -495,7 +511,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( sizeBytes: Number(size), projects: pruned.projects, eventCount: pruned.eventCount, - executedMigrations: executedMigrations.map(([id, name]) => `${id}_${name}`), + executedMigrations, }; }); diff --git a/apps/server/src/CommandInteraction.test.ts b/apps/server/src/CommandInteraction.test.ts new file mode 100644 index 00000000000..92df204beba --- /dev/null +++ b/apps/server/src/CommandInteraction.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { classifyCommandInteraction, commandInteractionSummary } from "./CommandInteraction.ts"; + +describe("command interactions", () => { + it.each([ + ["", null], + ["\u0003", "ctrl_c"], + ["\u0004", "ctrl_d"], + ["\n", "control"], + ["\u0001\u0002", "control"], + ["y\n", "input"], + ["secret", "input"], + ] as const)("classifies %j as %s", (stdin, expected) => { + expect(classifyCommandInteraction(stdin)).toBe(expected); + }); + + it.each([ + ["ctrl_c", "Sent Ctrl+C"], + ["ctrl_d", "Sent Ctrl+D"], + ["control", "Sent control input"], + ["input", "Sent input to command"], + ] as const)("labels %s without including input", (interaction, expected) => { + expect(commandInteractionSummary(interaction)).toBe(expected); + }); +}); diff --git a/apps/server/src/CommandInteraction.ts b/apps/server/src/CommandInteraction.ts new file mode 100644 index 00000000000..ebfec543208 --- /dev/null +++ b/apps/server/src/CommandInteraction.ts @@ -0,0 +1,30 @@ +import type { CommandInteractionKind } from "@t3tools/contracts"; + +export function classifyCommandInteraction(stdin: string): CommandInteractionKind | null { + if (stdin.length === 0) { + return null; + } + if (stdin === "\u0003") { + return "ctrl_c"; + } + if (stdin === "\u0004") { + return "ctrl_d"; + } + if ([...stdin].every((character) => character.charCodeAt(0) <= 0x1f || character === "\u007f")) { + return "control"; + } + return "input"; +} + +export function commandInteractionSummary(interaction: CommandInteractionKind): string { + switch (interaction) { + case "ctrl_c": + return "Sent Ctrl+C"; + case "ctrl_d": + return "Sent Ctrl+D"; + case "control": + return "Sent control input"; + case "input": + return "Sent input to command"; + } +} diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 0a1972c2827..954a3a709f4 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; +import { AssetPreviewTypeValidationError, ProjectId, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -31,6 +31,31 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("signs pull request file references without exposing provider credentials", () => + Effect.gen(function* () { + const result = yield* issueAssetUrl({ + resource: { + _tag: "pull-request-file", + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 7, + path: "docs/screenshot.png", + }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect(yield* resolveAsset(suffix.slice(0, separatorIndex), "screenshot.png")).toEqual({ + kind: "pull-request-file", + projectId: "project-1", + repository: "acme/web", + number: 7, + path: "docs/screenshot.png", + }); + expect(result.relativeUrl).not.toContain("acme"); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => 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 7157513b14d..d09e4e9748f 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,4 +1,4 @@ -import type { AssetResource } from "@t3tools/contracts"; +import type { AssetResource, ProjectId as ProjectIdType } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, AssetPreviewTypeValidationError, @@ -12,6 +12,8 @@ import { AssetWorkspacePathValidationError, AssetWorkspaceResolutionError, AssetWorkspaceRootNormalizationError, + PositiveInt, + ProjectId, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, @@ -88,6 +90,15 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.NullOr(Schema.String), expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("pull-request-file"), + projectId: ProjectId, + repository: Schema.String, + number: PositiveInt, + path: Schema.String, + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -95,7 +106,15 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +export type ResolvedAsset = + | { readonly kind: "file"; readonly path: string } + | { + readonly kind: "pull-request-file"; + readonly projectId: ProjectIdType; + readonly repository: string; + readonly number: number; + readonly path: string; + }; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -363,6 +382,19 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } break; } + case "pull-request-file": { + claims = { + version: 1, + kind: "pull-request-file", + projectId: input.resource.projectId, + repository: input.resource.repository, + number: input.resource.number, + path: input.resource.path, + expiresAt, + }; + fileName = path.basename(input.resource.path); + break; + } } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -441,6 +473,16 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; } + if (claims.kind === "pull-request-file") { + return { + kind: "pull-request-file", + projectId: claims.projectId, + repository: claims.repository, + number: claims.number, + path: claims.path, + } satisfies ResolvedAsset; + } + const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 36f348d6370..17b3bffa6eb 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -23,6 +23,7 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, + [ORCHESTRATION_WS_METHODS.getCommandOutput]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, @@ -75,6 +76,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, + [WS_METHODS.sourceControlGetDefaultRepository]: AuthOrchestrationReadScope, + [WS_METHODS.sourceControlSetDefaultRepository]: AuthOrchestrationOperateScope, + [WS_METHODS.sourceControlListIssues]: AuthOrchestrationReadScope, + [WS_METHODS.sourceControlGetIssue]: AuthOrchestrationReadScope, + [WS_METHODS.sourceControlResolveReferences]: AuthOrchestrationReadScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, @@ -83,6 +89,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, + [WS_METHODS.composerDraftUpdate]: AuthOrchestrationOperateScope, + [WS_METHODS.subscribeComposerDraft]: AuthOrchestrationReadScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd..1c70c362682 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + composerDraftSync: 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 a5f8fa659f9..6dbbc124dd0 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -575,6 +575,16 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), + listIssues: () => Effect.succeed([]), + resolveReferences: () => Effect.succeed([]), + getIssue: (input) => + Effect.fail( + new GitHubCli.GitHubIssueDecodeError({ + command: "gh", + cwd: input.cwd, + cause: new Error(`Unexpected issue view: ${input.reference}`), + }), + ), }, ghCalls, }; @@ -970,6 +980,133 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status skips the provider lookup for a branch with a local-only upstream", () => + 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/local-tracking"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to=main"]); + + const { manager, ghCalls } = yield* makeManager(); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.refName).toBe("feature/local-tracking"); + expect(status.pr).toBeNull(); + expect( + (yield* runGit(repoDir, ["config", "--get", "branch.feature/local-tracking.remote"])) + .stdout, + ).toBe(".\n"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); + }), + ); + + it.effect("status keeps the PR after its tracked remote branch is pruned", () => + 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/pruned-after-merge"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pruned-after-merge"]); + yield* runGit(repoDir, ["config", "fetch.prune", "true"]); + // Delete only the hosted branch so the local remote-tracking ref still + // exists until the status refresh fetches and prunes it. + yield* runGit(remoteDir, ["update-ref", "-d", "refs/heads/feature/pruned-after-merge"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 215, + title: "Merged PR with deleted branch", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRefName: "main", + headRefName: "feature/pruned-after-merge", + state: "MERGED", + mergedAt: "2026-04-02T15:00:00Z", + updatedAt: "2026-04-02T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.refName).toBe("feature/pruned-after-merge"); + expect(status.hasUpstream).toBe(false); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"], true)).exitCode, + ).not.toBe(0); + expect(status.pr).toEqual({ + number: 215, + title: "Merged PR with deleted branch", + url: "https://github.com/pingdotgg/t3code/pull/215", + baseRef: "main", + headRef: "feature/pruned-after-merge", + state: "merged", + }); + expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); + }), + ); + + it.effect("status recovers a differently named PR branch after its remote ref is pruned", () => + 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/local-name"]); + yield* runGit(repoDir, ["push", "-u", "origin", "HEAD:feature/hosted-name"]); + yield* runGit(repoDir, ["config", "fetch.prune", "true"]); + yield* runGit(remoteDir, ["update-ref", "-d", "refs/heads/feature/hosted-name"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + "feature/hosted-name": JSON.stringify([ + { + number: 216, + title: "Merged PR with a differently named head", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRefName: "main", + headRefName: "feature/hosted-name", + state: "MERGED", + mergedAt: "2026-04-03T15:00:00Z", + updatedAt: "2026-04-03T15:00:00Z", + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.refName).toBe("feature/local-name"); + expect(status.hasUpstream).toBe(false); + expect(status.pr).toEqual({ + number: 216, + title: "Merged PR with a differently named head", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRef: "main", + headRef: "feature/hosted-name", + state: "merged", + }); + expect(ghCalls).toContain( + "pr list --head feature/hosted-name --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), + ); + it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => 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 bed5a8839a7..a483c7d4712 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Order from "effect/Order"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { GitActionProgressEvent, @@ -179,6 +180,8 @@ interface BranchHeadContext { headSelectors: ReadonlyArray; preferredHeadSelector: string; remoteName: string | null; + configuredMergeRef: string | null; + trackingConfigReadSucceeded: boolean; headRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; @@ -264,6 +267,15 @@ function normalizeOptionalOwnerLogin(value: string | null | undefined): string | return normalized ? normalized.toLowerCase() : null; } +function branchNameFromConfiguredMergeRef(mergeRef: string | null): string | null { + const prefix = "refs/heads/"; + const normalized = normalizeOptionalString(mergeRef); + if (!normalized?.startsWith(prefix)) { + return null; + } + return normalizeOptionalString(normalized.slice(prefix.length)); +} + function resolvePullRequestHeadRepositoryNameWithOwner( pr: PullRequestHeadRemoteInfo & { url: string }, ) { @@ -1153,13 +1165,31 @@ export const make = Effect.gen(function* () { cwd: string, details: { branch: string; upstreamRef: string | null }, ) { - const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const remoteNameResult = yield* Effect.result( + gitCore.readConfigValue(cwd, `branch.${details.branch}.remote`), + ); + const configuredMergeRefResult = + details.upstreamRef === null + ? yield* Effect.result(gitCore.readConfigValue(cwd, `branch.${details.branch}.merge`)) + : null; + const remoteName = Result.isSuccess(remoteNameResult) ? remoteNameResult.success : null; + const configuredMergeRef = + configuredMergeRefResult !== null && Result.isSuccess(configuredMergeRefResult) + ? configuredMergeRefResult.success + : null; + const trackingConfigReadSucceeded = + Result.isSuccess(remoteNameResult) && + (configuredMergeRefResult === null || Result.isSuccess(configuredMergeRefResult)); const headBranchFromUpstream = details.upstreamRef ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; - const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; - const shouldProbeLocalBranchSelector = - headBranchFromUpstream.length === 0 || headBranch === details.branch; + const headBranchFromConfig = + remoteName === "." ? null : branchNameFromConfiguredMergeRef(configuredMergeRef); + const headBranch = + headBranchFromUpstream.length > 0 + ? headBranchFromUpstream + : (headBranchFromConfig ?? details.branch); + const shouldProbeLocalBranchSelector = headBranch === details.branch; const [remoteRepository, originRepository] = yield* Effect.all( [ @@ -1214,6 +1244,8 @@ export const make = Effect.gen(function* () { preferredHeadSelector: ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, remoteName, + configuredMergeRef, + trackingConfigReadSucceeded, headRemoteUrlKey: remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), @@ -1224,23 +1256,40 @@ export const make = Effect.gen(function* () { }); /** - * Whether git has no record of this branch on any remote, so a change request - * cannot exist for it and asking the provider is a guaranteed-empty API call. + * Whether git has no record that this branch was published, so 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. + * every remote so a fork branch still counts. A configured remote upstream + * also counts even when its remote-tracking ref is gone: hosts commonly + * delete a merged branch, and a pruning fetch removes the ref while leaving + * the local branch's remote/merge configuration intact. Local-only upstreams + * (`remote = .`) do not count. A repository that tracks no remotes at all + * cannot answer the question, because then every branch looks unpublished; + * it, and any failed probe or config read, keeps the lookup. */ const isUnpublishedBranch = Effect.fn("isUnpublishedBranch")(function* ( cwd: string, - headContext: Pick, + headContext: Pick< + BranchHeadContext, + "headBranch" | "remoteName" | "configuredMergeRef" | "trackingConfigReadSucceeded" + >, ) { if (headContext.headBranch.length === 0) { return false; } + if (!headContext.trackingConfigReadSucceeded) { + return false; + } + if ( + headContext.remoteName !== null && + headContext.remoteName !== "." && + headContext.configuredMergeRef !== null + ) { + return false; + } const matchesRef = (pattern: string) => gitCore .execute({ diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index ec4d2aae16e..36a26cc0397 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,7 +1,13 @@ import { expect, it } from "@effect/vitest"; import { describe } from "vite-plus/test"; -import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { + assetResponseHeaders, + isLoopbackHostname, + normalizeTerminalBrowserOpenUrl, + resolveDevRedirectUrl, + terminalBrowserOpenBearerToken, +} from "./http.ts"; describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -45,3 +51,20 @@ describe("assetResponseHeaders", () => { }); }); }); + +describe("terminal browser-open routing", () => { + it("accepts a non-empty bearer credential", () => { + expect(terminalBrowserOpenBearerToken("Bearer terminal-token")).toBe("terminal-token"); + expect(terminalBrowserOpenBearerToken("Basic terminal-token")).toBeNull(); + expect(terminalBrowserOpenBearerToken("Bearer ")).toBeNull(); + expect(terminalBrowserOpenBearerToken(undefined)).toBeNull(); + }); + + it("normalizes bounded http URLs and rejects unsupported targets", () => { + expect(normalizeTerminalBrowserOpenUrl("http://localhost:5173/app")).toBe( + "http://localhost:5173/app", + ); + expect(normalizeTerminalBrowserOpenUrl("file:///tmp/index.html")).toBeNull(); + expect(normalizeTerminalBrowserOpenUrl(`https://example.com/${"x".repeat(2_100)}`)).toBeNull(); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0da55686b92..a6d8137f951 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -6,12 +6,14 @@ import { } from "@t3tools/contracts"; import { isDevProxiedPath } from "@t3tools/shared/devProxy"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { cast } from "effect/Function"; import { HttpBody, @@ -38,12 +40,32 @@ import { failEnvironmentInternal, } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as TerminalBrowserOpen from "./preview/TerminalBrowserOpen.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; +import * as PullRequestService from "./pullRequest/PullRequestService.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; +const TERMINAL_BROWSER_OPEN_MAX_URL_LENGTH = 2_048; +const TerminalBrowserOpenBody = Schema.Struct({ url: Schema.String }); +const decodeTerminalBrowserOpenBody = Schema.decodeUnknownOption(TerminalBrowserOpenBody); + +export function terminalBrowserOpenBearerToken(authorization: string | undefined): string | null { + if (authorization?.startsWith("Bearer ") !== true) return null; + const token = authorization.slice("Bearer ".length).trim(); + return token.length > 0 ? token : null; +} + +export function normalizeTerminalBrowserOpenUrl(rawUrl: string): string | null { + if (rawUrl.length > TERMINAL_BROWSER_OPEN_MAX_URL_LENGTH) return null; + try { + return normalizePreviewUrl(rawUrl); + } catch { + return null; + } +} export function assetResponseHeaders(filePath: string): Record { return { @@ -194,34 +216,139 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( ), ); -export const assetRouteLayer = HttpRouter.add( - "GET", - `${ASSET_ROUTE_PREFIX}/*`, +export const terminalBrowserOpenRouteLayer = HttpRouter.add( + "POST", + TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_PATH, 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 browserOpen = yield* TerminalBrowserOpen.TerminalBrowserOpen; + const token = terminalBrowserOpenBearerToken(request.headers.authorization); + const owner = token ? yield* browserOpen.resolve(token) : undefined; + if (!owner) { + return HttpServerResponse.text("Unauthorized", { status: 401 }); } - const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - if (separatorIndex <= 0) { - return HttpServerResponse.text("Not Found", { status: 404 }); + const rawBody = yield* request.json.pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (body) => body, + }), + ); + const body = decodeTerminalBrowserOpenBody(rawBody); + if (Option.isNone(body)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + const url = normalizeTerminalBrowserOpenUrl(body.value.url); + if (!url) { + return HttpServerResponse.text("Bad Request", { status: 400 }); } - const asset = yield* resolveAsset( - suffix.slice(0, separatorIndex), - suffix.slice(separatorIndex + 1), + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const environmentId = yield* serverEnvironment.getEnvironmentId; + return yield* browserOpen.openInPreview({ environmentId, owner, url }).pipe( + Effect.as(HttpServerResponse.empty({ status: 204 })), + Effect.catch((cause) => + Effect.logWarning("failed to route terminal browser-open request to preview", { + threadId: owner.threadId, + terminalId: owner.terminalId, + cause, + }).pipe( + Effect.as( + HttpServerResponse.text("Preview host unavailable", { + status: 503, + }), + ), + ), + ), ); - if (!asset) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - return yield* HttpServerResponse.file(asset.path, { - status: 200, - headers: assetResponseHeaders(asset.path), - }).pipe( - Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), + }), +); + +export const assetRouteLayer = Layer.unwrap( + Effect.gen(function* () { + const pullRequests = yield* PullRequestService.PullRequestService; + return HttpRouter.add( + "GET", + `${ASSET_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 suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + if (separatorIndex <= 0) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const asset = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), + ); + if (!asset) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + if (asset.kind === "pull-request-file") { + const file = yield* pullRequests + .file({ + projectId: asset.projectId, + repository: asset.repository, + number: asset.number, + path: asset.path, + }) + .pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to resolve pull request image.", { + projectId: asset.projectId, + repository: asset.repository, + number: asset.number, + path: asset.path, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + if (file === null) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.get(file.url).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.tapError((cause) => + Effect.logWarning("Failed to download pull request image.", { + projectId: asset.projectId, + repository: asset.repository, + number: asset.number, + path: asset.path, + errorTag: cause._tag, + }), + ), + Effect.orElseSucceed(() => null), + ); + if (response === null) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + return HttpServerResponse.stream(response.stream, { + status: 200, + contentType: Mime.getType(asset.path) ?? "application/octet-stream", + contentLength: file.size, + headers: { + ...assetResponseHeaders(asset.path), + "Cache-Control": "private, max-age=300", + }, + }); + } + return yield* HttpServerResponse.file(asset.path, { + status: 200, + headers: assetResponseHeaders(asset.path), + }).pipe( + Effect.orElseSucceed(() => + HttpServerResponse.text("Internal Server Error", { status: 500 }), + ), + ); + }), ); }), ); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 3bc0fd71308..a82458cb8ff 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -62,24 +62,28 @@ it.effect("atomically registers a connected host and correlates its response", ( Effect.gen(function* () { const broker = yield* makeBroker; const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ + let routedPresentation: PreviewAutomationRequest["presentation"]; + yield* Stream.runForEach(requests, (request) => { + routedPresentation = request.presentation; + return broker.respond({ clientId: "client-1", connectionId: request.connectionId, requestId: request.requestId, ok: true, result: { available: true }, - }), - ).pipe(Effect.forkScoped); + }); + }).pipe(Effect.forkScoped); yield* Effect.yieldNow; const result = yield* broker.invoke<{ available: boolean }>({ scope, operation: "open", input: {}, + presentation: "right-panel", }); expect(result).toEqual({ available: true }); + expect(routedPresentation).toBe("right-panel"); }), ), ); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 3e9bfaac26f..ecc3aa5f18e 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -16,6 +16,7 @@ import { PreviewTabId, type PreviewAutomationError, type PreviewAutomationOperation, + type PreviewAutomationPresentation, type PreviewAutomationHost, type PreviewAutomationHostFocus, type PreviewAutomationResponse, @@ -40,6 +41,7 @@ export interface PreviewAutomationInvokeInput { readonly input: unknown; readonly tabId?: PreviewTabId; readonly timeoutMs?: number; + readonly presentation?: PreviewAutomationPresentation; } export class PreviewAutomationBroker extends Context.Service< @@ -535,6 +537,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { operation: input.operation, input: input.input, timeoutMs, + ...(input.presentation === undefined ? {} : { presentation: input.presentation }), }, }); if (!offered) { diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 886833d6e2c..c8df095a48d 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -74,6 +74,14 @@ export const terminalRestartsTotal = Metric.counter("t3_terminal_restarts_total" description: "Total terminal restart requests handled.", }); +export const setupScriptRunsTotal = Metric.counter("t3_setup_script_runs_total", { + description: "Total setup script runs by terminal outcome.", +}); + +export const setupScriptDuration = Metric.timer("t3_setup_script_duration", { + description: "Setup script run duration.", +}); + export const metricAttributes = ( attributes: Readonly>, ): ReadonlyArray<[string, string]> => Object.entries(compactMetricAttributes(attributes)); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index fc9ea4b6226..cac7607fd3c 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -14,6 +14,16 @@ function activity(payload: Record): OrchestrationThreadActivity } as unknown as OrchestrationThreadActivity; } +function commandActivity(data: Record): OrchestrationThreadActivity { + return activity({ + itemType: "command_execution", + status: "completed", + title: "Ran command", + detail: "/bin/zsh -lc 'echo ping'", + data, + }); +} + /** * Wire-survival regression: the slimming pass rewrites payload.data but must * never strip the top-level per-agent fields the subagent fold depends on. @@ -115,3 +125,330 @@ describe("projectActivityPayload agent-field survival", () => { expect(projected.payload).toEqual(source.payload); }); }); + +/** + * Clients name tool rows from `data.toolName` plus a few argument fields + * (`deriveToolRowPresentation`). Shipping those must not become a hole that + * puts file contents back on the wire — the whole point of this projection. + */ +describe("projectActivityPayload tool identity", () => { + it("keeps the tool name and identifying arguments for non-MCP tools", () => { + const projected = projectActivityPayload( + activity({ + itemType: "dynamic_tool_call", + title: "Tool call", + data: { + toolName: "Read", + input: { file_path: "/repo/src/app.ts", offset: 120, limit: 40 }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.toolName).toBe("Read"); + expect(data.input).toEqual({ file_path: "/repo/src/app.ts", offset: 120, limit: 40 }); + }); + + it("never ships file contents or message bodies", () => { + const projected = projectActivityPayload( + activity({ + itemType: "file_change", + data: { + toolName: "Edit", + input: { + file_path: "/repo/src/app.ts", + old_string: "a".repeat(50_000), + new_string: "b".repeat(50_000), + content: "c".repeat(50_000), + prompt: "d".repeat(50_000), + message: "e".repeat(50_000), + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.input).toEqual({ file_path: "/repo/src/app.ts" }); + expect(JSON.stringify(projected).length).toBeLessThan(1_000); + }); + + it("caps long allowlisted values", () => { + const projected = projectActivityPayload( + activity({ + itemType: "dynamic_tool_call", + data: { toolName: "Skill", input: { skill: "x", args: "y".repeat(5_000) } }, + }), + ); + const data = (projected.payload as Record).data as Record; + const input = data.input as Record; + expect((input.args as string).length).toBe(200); + expect(input.skill).toBe("x"); + }); + + it("caps MCP arguments too", () => { + const projected = projectActivityPayload( + activity({ + itemType: "mcp_tool_call", + data: { + toolName: "mcp__t3-code__preview_navigate", + input: { url: "https://example.com/".padEnd(5_000, "x") }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(((data.input as Record).url as string).length).toBe(200); + }); +}); + +describe("projectActivityPayload command exit codes", () => { + it("hides historical empty terminal polls and strips stdin", () => { + const projected = projectActivityPayload({ + ...activity({}), + kind: "tool.updated", + summary: "Tool updated", + payload: { + itemType: "command_execution", + data: { + itemId: "exec-1", + processId: "1234", + stdin: "", + threadId: "provider-thread-1", + turnId: "turn-1", + }, + }, + } as OrchestrationThreadActivity); + + expect(projected.payload).toMatchObject({ + itemType: "command_execution", + timelineBypass: true, + data: {}, + }); + expect(JSON.stringify(projected.payload)).not.toContain("stdin"); + }); + + it("projects historical Ctrl+C as a sanitized interaction", () => { + const projected = projectActivityPayload({ + ...activity({}), + kind: "tool.updated", + summary: "Tool updated", + payload: { + itemType: "command_execution", + data: { + itemId: "exec-1", + processId: "1234", + stdin: "\u0003", + threadId: "provider-thread-1", + turnId: "turn-1", + }, + }, + } as OrchestrationThreadActivity); + + expect(projected).toMatchObject({ + tone: "info", + kind: "command.interaction", + summary: "Sent Ctrl+C", + payload: { + interaction: "ctrl_c", + commandItemId: "exec-1", + }, + }); + expect(JSON.stringify(projected)).not.toContain("stdin"); + }); + + it("projects historical text input without exposing its content", () => { + const projected = projectActivityPayload({ + ...activity({}), + kind: "tool.updated", + summary: "Tool updated", + payload: { + itemType: "command_execution", + data: { + itemId: "exec-1", + processId: "1234", + stdin: "sensitive input", + threadId: "provider-thread-1", + turnId: "turn-1", + }, + }, + } as OrchestrationThreadActivity); + + expect(projected).toMatchObject({ + tone: "info", + kind: "command.interaction", + summary: "Sent input to command", + payload: { + interaction: "input", + commandItemId: "exec-1", + }, + }); + expect(JSON.stringify(projected)).not.toContain("sensitive input"); + }); + + it("does not hide unrelated command updates", () => { + const projected = projectActivityPayload({ + ...activity({}), + kind: "tool.updated", + payload: { + itemType: "command_execution", + data: { + item: { + command: "bun test", + status: "inProgress", + }, + }, + }, + } as OrchestrationThreadActivity); + + expect(projected.payload).not.toHaveProperty("timelineBypass"); + }); + + it("strips stdin even when a command update does not match the interaction shape", () => { + const projected = projectActivityPayload({ + ...activity({}), + kind: "tool.updated", + payload: { + itemType: "command_execution", + data: { + stdin: "sensitive input", + unexpectedFutureField: true, + }, + }, + } as OrchestrationThreadActivity); + + expect(projected.payload).not.toHaveProperty("timelineBypass"); + expect(JSON.stringify(projected.payload)).not.toContain("sensitive input"); + }); + + it("retains a Codex command exit code while dropping command output", () => { + const projected = projectActivityPayload( + commandActivity({ + completedAtMs: 1_785_974_254_706, + item: { + aggregatedOutput: "ping\n", + command: "/bin/zsh -lc 'echo ping'", + exitCode: 0, + status: "completed", + }, + }), + ); + + expect(projected.payload).toMatchObject({ + data: { + item: { + command: "/bin/zsh -lc 'echo ping'", + exitCode: 0, + }, + }, + }); + expect(JSON.stringify(projected.payload)).not.toContain("aggregatedOutput"); + }); + + it("retains an ACP command exit code while dropping command output", () => { + const projected = projectActivityPayload( + commandActivity({ + kind: "execute", + command: "bun run check", + rawOutput: { + exitCode: 17, + stdout: "check failed\nmore detail", + stderr: "", + }, + }), + ); + + expect(projected.payload).toMatchObject({ + data: { + kind: "execute", + command: "bun run check", + rawOutput: { + exitCode: 17, + }, + }, + }); + }); + + it("retains an exit code when ACP command output is empty", () => { + const projected = projectActivityPayload( + commandActivity({ + kind: "execute", + rawOutput: { + exitCode: 0, + stdout: "", + stderr: "", + }, + }), + ); + + expect(projected.payload).toMatchObject({ + data: { + rawOutput: { exitCode: 0 }, + }, + }); + }); +}); + +describe("projectActivityPayload file changes", () => { + it("keeps a compact path and line stat while dropping Edit source strings", () => { + const projected = projectActivityPayload( + activity({ + itemType: "file_change", + title: "File change", + detail: 'Edit: {"file_path":"/workspace/main.swift"}', + data: { + toolName: "Edit", + input: { + file_path: "/workspace/main.swift", + old_string: "one\ntwo\nthree", + new_string: "one\nupdated\nthree\nfour", + }, + }, + }), + ); + + expect(projected.payload).toMatchObject({ + data: { + files: [{ path: "/workspace/main.swift" }], + fileChangeStat: { additions: 2, deletions: 1 }, + }, + }); + expect(JSON.stringify(projected.payload)).not.toContain("old_string"); + expect(JSON.stringify(projected.payload)).not.toContain("new_string"); + }); + + it("derives line stats from Codex file-change diffs", () => { + const projected = projectActivityPayload( + activity({ + itemType: "file_change", + title: "File change", + data: { + item: { + type: "fileChange", + changes: [ + { + path: "src/app.ts", + kind: { type: "update" }, + diff: "@@ -1,3 +1,4 @@\n one\n-two\n+updated\n three\n+four", + }, + { + path: "README.md", + kind: { type: "add" }, + diff: "# New\n\nDetails\n", + }, + { + path: "old.txt", + kind: { type: "delete" }, + diff: "old\nlines\n", + }, + ], + }, + }, + }), + ); + + expect(projected.payload).toMatchObject({ + data: { + files: [{ path: "src/app.ts" }, { path: "README.md" }, { path: "old.txt" }], + fileChangeStat: { additions: 5, deletions: 3 }, + }, + }); + expect(JSON.stringify(projected.payload)).not.toContain("@@ -1,3"); + }); +}); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index f68a3ee96e9..0c2f372703f 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -3,6 +3,9 @@ import type { OrchestrationThreadActivity, OrchestrationThreadDetailSnapshot, } from "@t3tools/contracts"; +import { deriveToolFileChangeLineStat } from "@t3tools/shared/toolActivity"; + +import { classifyCommandInteraction, commandInteractionSummary } from "../CommandInteraction.ts"; function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -18,6 +21,10 @@ function asTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function asInteger(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) ? value : null; +} + function pushChangedFile(target: string[], seen: Set, value: unknown): void { const normalized = asTrimmedString(value); if (!normalized || seen.has(normalized)) { @@ -52,6 +59,7 @@ function collectChangedFiles( } pushChangedFile(target, seen, record.path); + pushChangedFile(target, seen, record.file_path); pushChangedFile(target, seen, record.filePath); pushChangedFile(target, seen, record.relativePath); pushChangedFile(target, seen, record.filename); @@ -82,28 +90,144 @@ function collectChangedFiles( function projectCommandData(data: Record): Record | undefined { const item = asRecord(data.item); - if (!item) { - return undefined; - } - const projectedItem: Record = {}; - if ("command" in item) { + if (item && "command" in item) { projectedItem.command = item.command; } + const exitCode = asInteger(item?.exitCode); + if (exitCode !== null) { + projectedItem.exitCode = exitCode; + } - const input = asRecord(item.input); + const input = asRecord(item?.input); if (input && "command" in input) { projectedItem.input = { command: input.command }; } - const result = asRecord(item.result); - if (result && "command" in result) { - projectedItem.result = { command: result.command }; + const result = asRecord(item?.result); + if (result) { + const projectedResult: Record = {}; + if ("command" in result) { + projectedResult.command = result.command; + } + const resultExitCode = asInteger(result.exitCode); + if (resultExitCode !== null) { + projectedResult.exitCode = resultExitCode; + } + if (Object.keys(projectedResult).length > 0) { + projectedItem.result = projectedResult; + } + } + + const state = asRecord(data.state); + const stateInput = asRecord(state?.input); + if (!("input" in projectedItem) && stateInput && "command" in stateInput) { + projectedItem.input = { command: stateInput.command }; + } + const stateMetadata = asRecord(state?.metadata); + const stateExitCode = state?.exitCode ?? stateMetadata?.exitCode; + if ( + !("exitCode" in projectedItem) && + typeof stateExitCode === "number" && + Number.isInteger(stateExitCode) + ) { + projectedItem.exitCode = stateExitCode; } return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; } +/** + * Input fields the clients need to name a tool row and show its one-line + * argument (`deriveToolRowPresentation`). Strictly an allowlist of short, + * identifying values: the same `input` object also carries `content`, + * `new_string`, `old_string`, `prompt` and message bodies, any of which would + * put whole file contents back on the wire and undo the payload slimming this + * projection exists for. Values are length-capped for the same reason. + */ +const TOOL_INPUT_KEPT_FIELDS = [ + "file_path", + "notebook_path", + "path", + "pattern", + "query", + "skill", + "args", + "to", + "subject", + // Short identifying prose. The long-form siblings (`prompt`, `message`, + // `content`) stay off this list; these two are capped like everything else. + "summary", + "description", + "url", + "offset", + "limit", +] as const; + +const TOOL_INPUT_VALUE_MAX_LENGTH = 200; + +function projectToolInputValue(value: unknown): unknown { + if (typeof value === "number" || typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return undefined; + } + return value.length <= TOOL_INPUT_VALUE_MAX_LENGTH + ? value + : `${value.slice(0, TOOL_INPUT_VALUE_MAX_LENGTH - 1)}…`; +} + +export function projectToolInput(value: unknown): Record | undefined { + const input = asRecord(value); + if (!input) { + return undefined; + } + const projected: Record = {}; + for (const key of TOOL_INPUT_KEPT_FIELDS) { + if (!(key in input)) { + continue; + } + const projectedValue = projectToolInputValue(input[key]); + if (projectedValue !== undefined) { + projected[key] = projectedValue; + } + } + return Object.keys(projected).length > 0 ? projected : undefined; +} + +/** + * MCP tools declare their own argument names, so an allowlist would blank the + * expanded row for every server. Keep the shape and cap the values instead: + * the risk here is one oversized argument, not an unbounded field set. + */ +const MCP_INPUT_NESTED_MAX_LENGTH = 500; + +function projectMcpToolInput(value: unknown): Record | undefined { + const input = asRecord(value); + if (!input) { + return undefined; + } + const projected: Record = {}; + for (const [key, entry] of Object.entries(input)) { + const primitive = projectToolInputValue(entry); + if (primitive !== undefined) { + projected[key] = primitive; + continue; + } + if (entry === null || typeof entry !== "object") { + continue; + } + // Structured arguments are worth keeping while they stay small; a large + // one is a payload in disguise. + const encoded = JSON.stringify(entry); + if (encoded !== undefined && encoded.length <= MCP_INPUT_NESTED_MAX_LENGTH) { + projected[key] = entry; + } + } + return Object.keys(projected).length > 0 ? projected : undefined; +} + function summarizeToolTextOutput(value: string): string | null { const lines: string[] = []; for (const rawLine of value.split(/\r?\n/u)) { @@ -205,8 +329,9 @@ function projectMcpToolCallData(data: Record): Record): Record | undefined { +function projectRawOutput( + value: unknown, + options?: { preserveOnlyExitCode?: boolean }, +): Record | undefined { const rawOutput = asRecord(value); if (!rawOutput) { return undefined; } + const projected: Record = {}; + const exitCode = asInteger(rawOutput.exitCode); + if (exitCode !== null) { + projected.exitCode = exitCode; + } + if (typeof rawOutput.totalFiles === "number" && Number.isFinite(rawOutput.totalFiles)) { - return { - totalFiles: rawOutput.totalFiles, - ...(rawOutput.truncated === true ? { truncated: true } : {}), - }; + projected.totalFiles = rawOutput.totalFiles; + if (rawOutput.truncated === true) { + projected.truncated = true; + } + return projected; + } + + if (options?.preserveOnlyExitCode) { + return exitCode !== null ? { exitCode } : undefined; } const content = asTrimmedString(rawOutput.content); if (content) { const summary = summarizeToolTextOutput(content); - return summary ? { content: summary } : undefined; + if (summary) { + projected.content = summary; + } + return Object.keys(projected).length > 0 ? projected : undefined; } const stdout = asTrimmedString(rawOutput.stdout); if (stdout) { const summary = summarizeToolTextOutput(stdout); - return summary ? { content: summary } : undefined; + if (summary) { + projected.content = summary; + } + } + + return Object.keys(projected).length > 0 ? projected : undefined; +} + +function isCodexTerminalInteractionActivity( + activity: OrchestrationThreadActivity, + payload: Record, + data: Record, +): boolean { + if (activity.kind !== "tool.updated" || payload.itemType !== "command_execution") { + return false; + } + + const expectedKeys = new Set(["itemId", "processId", "stdin", "threadId", "turnId"]); + return ( + Object.keys(data).every((key) => expectedKeys.has(key)) && + typeof data.itemId === "string" && + typeof data.processId === "string" && + typeof data.stdin === "string" && + typeof data.threadId === "string" && + typeof data.turnId === "string" + ); +} + +function projectCodexTerminalInteraction( + activity: OrchestrationThreadActivity, + payload: Record, + data: Record, +): OrchestrationThreadActivity | null { + if (!isCodexTerminalInteractionActivity(activity, payload, data)) { + return null; + } + + const interaction = classifyCommandInteraction(data.stdin as string); + if (!interaction) { + return { + ...activity, + payload: { + ...payload, + timelineBypass: true, + data: {}, + }, + }; } - return undefined; + return { + ...activity, + tone: "info", + kind: "command.interaction", + summary: commandInteractionSummary(interaction), + payload: { + interaction, + commandItemId: data.itemId, + }, + }; } /** @@ -272,6 +469,14 @@ export function projectActivityPayload( return activity; } + // Older event stores contain Codex terminal-interaction notifications that + // were projected as anonymous command updates. Reclassify meaningful input + // without shipping stdin, and keep empty status polls out of the timeline. + const terminalInteraction = projectCodexTerminalInteraction(activity, payload, data); + if (terminalInteraction) { + return terminalInteraction; + } + if (payload.itemType === "mcp_tool_call") { return { ...activity, @@ -290,6 +495,14 @@ export function projectActivityPayload( if ("command" in data) { projectedData.command = data.command; } + const exitCode = asInteger(data.exitCode); + if (exitCode !== null) { + projectedData.exitCode = exitCode; + } + const resultExitCode = asInteger(asRecord(data.result)?.exitCode); + if (resultExitCode !== null) { + projectedData.result = { exitCode: resultExitCode }; + } const changedFiles: string[] = []; collectChangedFiles(data, changedFiles, new Set(), 0); @@ -297,6 +510,12 @@ export function projectActivityPayload( // Both clients discover file names by walking objects with path-like keys. projectedData.files = changedFiles.map((path) => ({ path })); } + if (payload.itemType === "file_change") { + const fileChangeStat = deriveToolFileChangeLineStat(data); + if (fileChangeStat) { + projectedData.fileChangeStat = fileChangeStat; + } + } if ("toolCallId" in data) { projectedData.toolCallId = data.toolCallId; @@ -305,17 +524,39 @@ export function projectActivityPayload( projectedData.kind = data.kind; } - const rawOutput = projectRawOutput(data.rawOutput); + // The tool's own name is the only signal that distinguishes a Read from a + // Skill from a SendMessage: itemType collapses all three into + // `dynamic_tool_call`, and the adapters' titles say "Tool call" for every + // one of them. Clients name rows from this (`deriveToolRowPresentation`). + if ("toolName" in data) { + projectedData.toolName = data.toolName; + } + const toolInput = projectToolInput(data.input); + if (toolInput) { + projectedData.input = toolInput; + } + + const rawOutput = projectRawOutput(data.rawOutput, { + preserveOnlyExitCode: payload.itemType === "command_execution", + }); if (rawOutput) { projectedData.rawOutput = rawOutput; } + const projectedPayload: Record = { + ...payload, + data: projectedData, + }; + if (payload.itemType === "command_execution") { + const state = asRecord(data.state); + if (payload.detail === state?.output || payload.detail === state?.error) { + delete projectedPayload.detail; + } + } + return { ...activity, - payload: { - ...payload, - data: projectedData, - }, + payload: projectedPayload, }; } diff --git a/apps/server/src/orchestration/CommandOutputQuery.test.ts b/apps/server/src/orchestration/CommandOutputQuery.test.ts new file mode 100644 index 00000000000..2e27ce81d41 --- /dev/null +++ b/apps/server/src/orchestration/CommandOutputQuery.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeCommandOutput } from "./CommandOutputQuery.ts"; + +describe("normalizeCommandOutput", () => { + it("returns Codex combined output with its exit code", () => { + expect( + normalizeCommandOutput({ + kind: "tool.completed", + payload: { + itemType: "command_execution", + data: { + item: { + aggregatedOutput: "stdout line\nstderr line\n", + exitCode: 7, + }, + }, + }, + }), + ).toEqual({ + status: "available", + output: "stdout line\nstderr line\n", + stdout: null, + stderr: null, + exitCode: 7, + }); + }); + + it("keeps ACP stdout and stderr separate", () => { + expect( + normalizeCommandOutput({ + kind: "tool.completed", + payload: { + itemType: "command_execution", + status: "completed", + data: { + rawOutput: { + stdout: "stdout line\n", + stderr: "stderr line\n", + exitCode: 2, + }, + }, + }, + }), + ).toEqual({ + status: "available", + output: null, + stdout: "stdout line\n", + stderr: "stderr line\n", + exitCode: 2, + }); + }); + + it("truncates long output in the middle", () => { + const result = normalizeCommandOutput({ + kind: "tool.completed", + payload: { + itemType: "command_execution", + status: "completed", + data: { + item: { + aggregatedOutput: `start-${"x".repeat(300_000)}-end`, + }, + }, + }, + }); + + expect(result.output).toMatch(/^start-/u); + expect(result.output).toContain("… output truncated …"); + expect(result.output).toMatch(/-end$/u); + }); +}); diff --git a/apps/server/src/orchestration/CommandOutputQuery.ts b/apps/server/src/orchestration/CommandOutputQuery.ts new file mode 100644 index 00000000000..2cda4857dd7 --- /dev/null +++ b/apps/server/src/orchestration/CommandOutputQuery.ts @@ -0,0 +1,187 @@ +import { + OrchestrationGetCommandOutputInput, + type OrchestrationGetCommandOutputResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type PersistenceDecodeError, + type PersistenceSqlError, +} from "../persistence/Errors.ts"; + +const MAX_OUTPUT_LENGTH = 256 * 1024; +const TRUNCATION_MARKER = "\n… output truncated …\n"; +const CommandActivityRow = Schema.Struct({ + kind: Schema.String, + payload: Schema.fromJsonString(Schema.Unknown), +}); +type CommandActivityRow = typeof CommandActivityRow.Type; + +const emptyResult = ( + status: OrchestrationGetCommandOutputResult["status"], + exitCode: number | null = null, +): OrchestrationGetCommandOutputResult => ({ + status, + output: null, + stdout: null, + stderr: null, + exitCode, +}); + +export class CommandOutputQuery extends Context.Reference<{ + readonly getCommandOutput: ( + input: OrchestrationGetCommandOutputInput, + ) => Effect.Effect< + OrchestrationGetCommandOutputResult, + PersistenceSqlError | PersistenceDecodeError + >; +}>("t3/orchestration/CommandOutputQuery", { + defaultValue: () => ({ + getCommandOutput: () => Effect.succeed(emptyResult("unavailable")), + }), +}) {} + +function readText(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function readInteger(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) ? value : null; +} + +function readToolResultText(value: unknown): string | null { + const direct = readText(value); + if (direct) return direct; + if (Array.isArray(value)) { + const parts = value.flatMap((entry) => { + if (typeof entry === "string") return [entry]; + if (!Predicate.isObject(entry)) return []; + return [readText(entry.text), readText(entry.content)].filter( + (part): part is string => part !== null, + ); + }); + return parts.length > 0 ? parts.join("\n") : null; + } + if (!Predicate.isObject(value)) return null; + return readToolResultText(value.content) ?? readText(value.text) ?? readText(value.output); +} + +function truncateMiddle(value: string, limit: number): string { + if (value.length <= limit) return value; + const retainedLength = limit - TRUNCATION_MARKER.length; + const headLength = Math.ceil(retainedLength / 2); + return `${value.slice(0, headLength)}${TRUNCATION_MARKER}${value.slice( + -(retainedLength - headLength), + )}`; +} + +function parseLegacyOutput(detail: unknown) { + const value = readText(detail); + const match = value?.match(/(?:\r?\n)?\s*$/iu); + if (!value || !match) return { output: null, exitCode: null }; + const exitCode = Number(match[1]); + return { + output: readText(value.slice(0, match.index)), + exitCode: Number.isSafeInteger(exitCode) ? exitCode : null, + }; +} + +export function normalizeCommandOutput( + row: CommandActivityRow | null, +): OrchestrationGetCommandOutputResult { + if (row === null || !Predicate.isObject(row.payload)) return emptyResult("unavailable"); + const payload = row.payload; + if (payload.itemType !== "command_execution") return emptyResult("unavailable"); + + const data = Predicate.isObject(payload.data) ? payload.data : {}; + const item = Predicate.isObject(data.item) ? data.item : {}; + const rawOutput = Predicate.isObject(data.rawOutput) ? data.rawOutput : {}; + const state = Predicate.isObject(data.state) ? data.state : {}; + const stateMetadata = Predicate.isObject(state.metadata) ? state.metadata : {}; + const legacy = parseLegacyOutput(payload.detail); + const exitCode = + readInteger(item.exitCode) ?? + readInteger(rawOutput.exitCode) ?? + readInteger(state.exitCode) ?? + readInteger(stateMetadata.exitCode) ?? + legacy.exitCode; + const stdout = readText(rawOutput.stdout); + const stderr = readText(rawOutput.stderr); + if (stdout !== null || stderr !== null) { + const streamLimit = Math.floor(MAX_OUTPUT_LENGTH / 2); + return { + status: "available", + output: null, + stdout: stdout === null ? null : truncateMiddle(stdout, streamLimit), + stderr: stderr === null ? null : truncateMiddle(stderr, streamLimit), + exitCode, + }; + } + + const output = + readText(item.aggregatedOutput) ?? + readText(state.output) ?? + readText(state.error) ?? + readToolResultText(data.result) ?? + readText(rawOutput.content) ?? + legacy.output; + if (output !== null) { + return { + status: "available", + output: truncateMiddle(output, MAX_OUTPUT_LENGTH), + stdout: null, + stderr: null, + exitCode, + }; + } + + const lifecycleStatus = + readText(payload.status) ?? readText(item.status) ?? readText(state.status); + return emptyResult( + row.kind === "tool.completed" || lifecycleStatus === "completed" || lifecycleStatus === "failed" + ? "available" + : "unavailable", + exitCode, + ); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const getCommandActivityRow = SqlSchema.findOneOption({ + Request: OrchestrationGetCommandOutputInput, + Result: CommandActivityRow, + execute: ({ threadId, activityId }) => + sql` + SELECT kind, payload_json AS "payload" + FROM projection_thread_activities + WHERE thread_id = ${threadId} AND activity_id = ${activityId} + LIMIT 1 + `, + }); + + const getCommandOutput: (typeof CommandOutputQuery.Service)["getCommandOutput"] = Effect.fn( + "CommandOutputQuery.getCommandOutput", + )(function* (input) { + const row = yield* getCommandActivityRow(input).pipe( + Effect.mapError((cause) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError("CommandOutputQuery.getCommandOutput:decodeRow")(cause) + : toPersistenceSqlError("CommandOutputQuery.getCommandOutput:query")(cause), + ), + ); + return normalizeCommandOutput(Option.getOrNull(row)); + }); + + return { getCommandOutput }; +}); + +export const layer = Layer.effect(CommandOutputQuery, make); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a..b37bb172821 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -239,6 +239,50 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); }), ); + + it.effect("removes the composer draft when its thread is deleted", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-with-composer-draft"); + const now = "2026-01-01T00:00:00.000Z"; + const commonJson = + '{"text":"delete me","modelSelection":null,"runtimeMode":null,"interactionMode":null}'; + + yield* sql` + INSERT INTO composer_drafts ( + thread_id, revision, common_json, updated_at, client_mutation_id + ) VALUES ( + ${threadId}, 1, ${commonJson}, ${now}, 'test:delete-composer-draft' + ) + `; + yield* eventStore.append({ + type: "thread.deleted", + eventId: EventId.make("evt-delete-composer-draft"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-delete-composer-draft"), + causationEventId: null, + correlationId: CommandId.make("cmd-delete-composer-draft"), + metadata: {}, + payload: { + threadId, + deletedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + + const remaining = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM composer_drafts + WHERE thread_id = ${threadId} + `; + assert.deepEqual(remaining, [{ count: 0 }]); + }), + ); }); it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91c..978ed0cf7b2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -836,6 +836,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.deleted": { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); + yield* sql` + DELETE FROM composer_drafts + WHERE thread_id = ${event.payload.threadId} + `.pipe(Effect.mapError(toPersistenceSqlError("delete thread composer draft"))); const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605..a9f445ec12b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -552,6 +552,106 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("adopts a pending follow-up when the provider keeps it on the active turn", async () => { + const harness = await createHarness(); + const threadId = ThreadId.make("thread-1"); + const activeTurnId = asTurnId("turn-active"); + const nextTurnId = asTurnId("turn-next"); + const startedAt = "2026-01-01T00:00:00.000Z"; + const followUpAt = "2026-01-01T00:00:01.000Z"; + const nextTurnAt = "2026-01-01T00:00:02.000Z"; + + harness.runtimeSessions.push({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + runtimeMode: "approval-required", + threadId, + cwd: "/tmp/provider-project", + model: "gpt-5-codex", + activeTurnId, + resumeCursor: { opaque: "resume-active" }, + createdAt: startedAt, + updatedAt: startedAt, + }); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-active-turn"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId, + lastError: null, + updatedAt: startedAt, + }, + createdAt: startedAt, + }), + ); + harness.sendTurn.mockReturnValueOnce( + Effect.succeed({ + threadId, + turnId: activeTurnId, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-same-turn-follow-up"), + threadId, + message: { + messageId: asMessageId("user-message-same-turn-follow-up"), + role: "user", + text: "also check the related issue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: followUpAt, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + return thread?.session?.updatedAt === followUpAt; + }); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-next-turn"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: nextTurnId, + lastError: null, + updatedAt: nextTurnAt, + }, + createdAt: nextTurnAt, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === threadId)?.latestTurn?.turnId === nextTurnId + ); + }); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + expect(thread?.latestTurn?.requestedAt).toBe(nextTurnAt); + }); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); @@ -1552,6 +1652,54 @@ describe("ProviderCommandReactor", () => { }); }); + it("keeps setup-script work-log activities out of provider input", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-setup-script-started"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-setup-script-started"), + tone: "info", + kind: "setup-script.started", + summary: "Setup script started", + payload: { + runId: "setup-run-1", + command: "bun install", + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-after-setup-activity"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-after-setup-activity"), + role: "user", + text: "Implement the requested change.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + const request = harness.sendTurn.mock.calls[0]?.[0]; + expect(request).toMatchObject({ input: "Implement the requested change." }); + expect(JSON.stringify(request)).not.toContain("Setup script started"); + expect(JSON.stringify(request)).not.toContain("bun install"); + }); + it("forwards claude effort options through session start and turn send", async () => { const harness = await createHarness({ threadModelSelection: { @@ -2806,7 +2954,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input-error"), @@ -2824,7 +2972,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-user-input-requested"), @@ -2857,7 +3005,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond-stale"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613f..0a84df3f328 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1168,9 +1168,45 @@ const make = Effect.gen(function* () { return; } - yield* providerService - .sendTurn(sendTurnRequest.value) - .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); + const activeTurnId = thread.session?.status === "running" ? thread.session.activeTurnId : null; + + yield* providerService.sendTurn(sendTurnRequest.value).pipe( + Effect.tap((startedTurn) => { + if ( + activeTurnId === null || + startedTurn.turnId !== activeTurnId || + thread.session === null + ) { + return Effect.void; + } + + // Codex can accept a follow-up as same-turn steering: turn/start + // returns the already-active turn id and no second turn.started event + // follows. Re-asserting that running session lets the turn projection + // adopt and clear the pending start before the shared turn completes. + return setThreadSession({ + threadId: thread.id, + session: { + ...thread.session, + status: "running", + activeTurnId, + lastError: null, + updatedAt: event.payload.createdAt, + }, + createdAt: event.payload.createdAt, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to adopt same-turn follow-up", { + threadId: thread.id, + activeTurnId, + cause: Cause.pretty(cause), + }), + ), + ); + }), + Effect.catchCause(recoverTurnStartFailure), + Effect.forkScoped, + ); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 449b1fbf513..55a402deb76 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1656,6 +1656,78 @@ describe("ProviderRuntimeIngestion", () => { expect(threadAfterSteer.latestTurn?.state).toBe("running"); }); + effectIt.effect( + "keeps a pending steer in progress when the superseded turn completes first", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = asThreadId("thread-1"); + const oldTurnId = asTurnId("turn-before-pending-steer"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-before-pending-steer"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: oldTurnId, + }); + yield* Effect.promise(() => harness.drain()); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-pending-steer-before-old-completion"), + threadId, + message: { + messageId: asMessageId("msg-pending-steer-before-old-completion"), + role: "user", + text: "continue with this follow-up", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-ready-after-pending-steer"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:02.000Z", + threadId, + payload: { state: "ready" }, + }); + yield* Effect.promise(() => harness.drain()); + + let thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.activeTurnId).toBeNull(); + expect(thread?.latestTurn?.state).toBe("running"); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-old-turn-completed-after-pending-steer"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:03.000Z", + threadId, + turnId: oldTurnId, + status: "completed", + }); + yield* Effect.promise(() => harness.drain()); + + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.activeTurnId).toBeNull(); + expect(thread?.latestTurn?.turnId).toBe(oldTurnId); + expect(thread?.latestTurn?.state).toBe("running"); + expect(thread?.latestTurn?.completedAt).toBeNull(); + }), + ); + it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => { const harness = await createHarness(); const sourceThreadId = asThreadId("thread-plan"); @@ -2890,6 +2962,19 @@ describe("ProviderRuntimeIngestion", () => { }, }); + harness.emit({ + type: "command.interaction", + eventId: asEventId("evt-command-interaction"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-p1"), + itemId: asItemId("item-p1-tool"), + payload: { + interaction: "ctrl_c", + }, + }); + harness.emit({ type: "runtime.warning", eventId: asEventId("evt-runtime-warning"), @@ -2926,6 +3011,9 @@ describe("ProviderRuntimeIngestion", () => { entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.updated", ) && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "command.interaction", + ) && entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "runtime.warning", ) && @@ -2957,6 +3045,19 @@ describe("ProviderRuntimeIngestion", () => { expect(toolUpdatePayload?.itemType).toBe("command_execution"); expect(toolUpdatePayload?.status).toBe("in_progress"); + const commandInteraction = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-command-interaction", + ); + expect(commandInteraction).toMatchObject({ + kind: "command.interaction", + tone: "info", + summary: "Sent Ctrl+C", + payload: { + interaction: "ctrl_c", + commandItemId: "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 c942960f3c6..7f6bee74c75 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -44,6 +44,8 @@ import { import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { canReplaceThreadTitle } from "../threadTitles.ts"; +import { commandInteractionSummary } from "../../CommandInteraction.ts"; +import { projectToolInput } from "../ActivityPayloadProjection.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; @@ -358,6 +360,24 @@ function taskLinkageActivityFields(payload: Record): Record { + const record = + args !== null && typeof args === "object" && !Array.isArray(args) + ? (args as Record) + : undefined; + const toolName = typeof record?.toolName === "string" ? record.toolName.trim() : ""; + const input = projectToolInput(record?.input); + return { + ...(toolName.length > 0 ? { toolName } : {}), + ...(input ? { toolInput: input } : {}), + }; +} + export function runtimeEventToActivities( event: ProviderRuntimeEvent, taskTitle?: string, @@ -393,6 +413,10 @@ export function runtimeEventToActivities( ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.detail ? { detail: event.payload.detail } : {}), + // Lets the approval card name the tool the way the timeline does; + // `requestType` only distinguishes command/read/change, so a + // TaskCreate prompt otherwise reads as a file change. + ...approvalToolFields(event.payload.args), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -809,6 +833,24 @@ export function runtimeEventToActivities( ]; } + case "command.interaction": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "command.interaction", + summary: commandInteractionSummary(event.payload.interaction), + payload: { + interaction: event.payload.interaction, + ...(event.itemId ? { commandItemId: event.itemId } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + case "item.completed": { if (!isToolLifecycleItemType(event.payload.itemType)) { return []; @@ -1494,8 +1536,10 @@ const make = Effect.gen(function* () { const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ threadId: thread.id, }); - const hasPendingTurnStart = - Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; + // A pending start is the lifecycle authority even while the previous + // turn is still running: providers can report that old turn ready or + // completed before they announce the replacement turn. + const hasPendingTurnStart = Option.isSome(pendingTurnStart); const conflictsWithActiveTurn = activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); @@ -1571,7 +1615,9 @@ const make = Effect.gen(function* () { case "turn.completed": return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" - : "ready"; + : hasPendingTurnStart + ? "starting" + : "ready"; case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an diff --git a/apps/server/src/persistence/ComposerDrafts.test.ts b/apps/server/src/persistence/ComposerDrafts.test.ts new file mode 100644 index 00000000000..12c49049ee4 --- /dev/null +++ b/apps/server/src/persistence/ComposerDrafts.test.ts @@ -0,0 +1,119 @@ +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ComposerDrafts from "./ComposerDrafts.ts"; +import { runAllMigrations } from "./ForkMigrations.ts"; +import * as NodeSqliteClient from "./NodeSqliteClient.ts"; + +const layer = it.layer( + ComposerDrafts.layer.pipe(Layer.provideMerge(NodeSqliteClient.layerMemory())), +); + +layer("ComposerDraftRepository", (it) => { + it.effect("uses revision compare-and-swap and preserves the winning snapshot", () => + Effect.gen(function* () { + yield* runAllMigrations(); + const repository = yield* ComposerDrafts.ComposerDraftRepository; + const threadId = ThreadId.make("draft-cas-thread"); + const common = { + text: "hello from device one", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + }; + + const accepted = yield* repository.update({ + threadId, + baseRevision: 0, + common, + clientMutationId: "device-one-1", + }); + assert.equal(accepted._tag, "accepted"); + assert.equal(accepted.snapshot.revision, 1); + + const conflict = yield* repository.update({ + threadId, + baseRevision: 0, + common: { ...common, text: "stale device" }, + clientMutationId: "device-two-1", + }); + assert.equal(conflict._tag, "conflict"); + assert.deepEqual(conflict.snapshot.common, common); + assert.equal(conflict.snapshot.revision, 1); + }), + ); + + it.effect("keeps clears as revisioned tombstones", () => + Effect.gen(function* () { + yield* runAllMigrations(); + const repository = yield* ComposerDrafts.ComposerDraftRepository; + const threadId = ThreadId.make("draft-tombstone-thread"); + + yield* repository.update({ + threadId, + baseRevision: 0, + common: { + text: "sent later", + modelSelection: null, + runtimeMode: null, + interactionMode: null, + }, + clientMutationId: "write-1", + }); + const cleared = yield* repository.update({ + threadId, + baseRevision: 1, + common: null, + clientMutationId: "clear-2", + }); + + assert.equal(cleared._tag, "accepted"); + assert.equal(cleared.snapshot.revision, 2); + assert.isNull(cleared.snapshot.common); + assert.deepEqual(yield* repository.get({ threadId }), cleared.snapshot); + }), + ); + + it.effect("does not let a delayed send clear a newer device revision", () => + Effect.gen(function* () { + yield* runAllMigrations(); + const repository = yield* ComposerDrafts.ComposerDraftRepository; + const threadId = ThreadId.make("draft-delayed-send-thread"); + const first = { + text: "message being sent", + modelSelection: null, + runtimeMode: null, + interactionMode: null, + }; + const newer = { ...first, text: "new text from another device" }; + + yield* repository.update({ + threadId, + baseRevision: 0, + common: first, + clientMutationId: "first-device", + }); + yield* repository.update({ + threadId, + baseRevision: 1, + common: newer, + clientMutationId: "second-device", + }); + const delayedClear = yield* repository.update({ + threadId, + baseRevision: 1, + common: null, + clientMutationId: "delayed-send", + }); + + assert.equal(delayedClear._tag, "conflict"); + assert.equal(delayedClear.snapshot.revision, 2); + assert.deepEqual(delayedClear.snapshot.common, newer); + }), + ); +}); diff --git a/apps/server/src/persistence/ComposerDrafts.ts b/apps/server/src/persistence/ComposerDrafts.ts new file mode 100644 index 00000000000..63b84b18126 --- /dev/null +++ b/apps/server/src/persistence/ComposerDrafts.ts @@ -0,0 +1,208 @@ +import { + ComposerDraftCommon, + type ComposerDraftGetInput, + type ComposerDraftSnapshot, + type ComposerDraftUpdateInput, + type ComposerDraftUpdateResult, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass()( + "ComposerDraftPersistenceError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) {} + +export class ComposerDraftRepository extends Context.Service< + ComposerDraftRepository, + { + readonly get: ( + input: ComposerDraftGetInput, + ) => Effect.Effect; + readonly update: ( + input: ComposerDraftUpdateInput, + ) => Effect.Effect; + readonly subscribe: ( + input: ComposerDraftGetInput, + ) => Stream.Stream; + } +>()("t3/persistence/ComposerDrafts/ComposerDraftRepository") {} + +const DbRow = Schema.Struct({ + threadId: ThreadId, + revision: Schema.Int, + common: Schema.NullOr(Schema.fromJsonString(ComposerDraftCommon)), + updatedAt: Schema.String, + clientMutationId: Schema.String, +}); + +const RawDbRow = Schema.Struct({ + threadId: Schema.Unknown, + revision: Schema.Unknown, + common: Schema.Unknown, + updatedAt: Schema.Unknown, + clientMutationId: Schema.Unknown, +}); + +const WriteRow = Schema.Struct({ + threadId: ThreadId, + baseRevision: Schema.Int, + nextRevision: Schema.Int, + common: Schema.NullOr(Schema.fromJsonString(ComposerDraftCommon)), + updatedAt: Schema.String, + clientMutationId: Schema.String, +}); + +const decodeRow = Schema.decodeUnknownEffect(DbRow); +const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +function emptySnapshot(threadId: ThreadId): ComposerDraftSnapshot { + return { + threadId, + revision: 0, + common: null, + updatedAt: null, + clientMutationId: null, + }; +} + +function toSnapshot(row: typeof DbRow.Type): ComposerDraftSnapshot { + return { + threadId: row.threadId, + revision: row.revision, + common: row.common, + updatedAt: row.updatedAt, + clientMutationId: row.clientMutationId, + }; +} + +function mapPersistenceError(operation: string) { + return (cause: unknown) => new ComposerDraftPersistenceError({ operation, cause }); +} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const changes = yield* PubSub.unbounded(); + + const getRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ threadId: ThreadId }), + Result: RawDbRow, + execute: ({ threadId }) => sql` + SELECT + thread_id AS "threadId", + revision, + common_json AS "common", + updated_at AS "updatedAt", + client_mutation_id AS "clientMutationId" + FROM composer_drafts + WHERE thread_id = ${threadId} + `, + }); + + const insertRow = SqlSchema.findAll({ + Request: WriteRow, + Result: RawDbRow, + execute: (row) => sql` + INSERT INTO composer_drafts ( + thread_id, revision, common_json, updated_at, client_mutation_id + ) VALUES ( + ${row.threadId}, ${row.nextRevision}, ${row.common}, ${row.updatedAt}, + ${row.clientMutationId} + ) + ON CONFLICT (thread_id) DO NOTHING + RETURNING + thread_id AS "threadId", + revision, + common_json AS "common", + updated_at AS "updatedAt", + client_mutation_id AS "clientMutationId" + `, + }); + + const updateRow = SqlSchema.findAll({ + Request: WriteRow, + Result: RawDbRow, + execute: (row) => sql` + UPDATE composer_drafts + SET + revision = ${row.nextRevision}, + common_json = ${row.common}, + updated_at = ${row.updatedAt}, + client_mutation_id = ${row.clientMutationId} + WHERE thread_id = ${row.threadId} + AND revision = ${row.baseRevision} + RETURNING + thread_id AS "threadId", + revision, + common_json AS "common", + updated_at AS "updatedAt", + client_mutation_id AS "clientMutationId" + `, + }); + + const get: ComposerDraftRepository["Service"]["get"] = Effect.fn("ComposerDraftRepository.get")( + function* (input) { + const row = yield* getRow(input).pipe( + Effect.mapError(mapPersistenceError("ComposerDraftRepository.get:query")), + ); + if (Option.isNone(row)) return emptySnapshot(input.threadId); + const decoded = yield* decodeRow(row.value).pipe( + Effect.mapError(mapPersistenceError("ComposerDraftRepository.get:decode")), + ); + return toSnapshot(decoded); + }, + ); + + const update: ComposerDraftRepository["Service"]["update"] = Effect.fn( + "ComposerDraftRepository.update", + )(function* (input) { + const write = { + ...input, + nextRevision: input.baseRevision + 1, + updatedAt: yield* currentIsoTimestamp, + }; + const rows = yield* (input.baseRevision === 0 ? insertRow(write) : updateRow(write)).pipe( + Effect.mapError(mapPersistenceError("ComposerDraftRepository.update:query")), + ); + const row = rows[0]; + if (row === undefined) { + return { _tag: "conflict", snapshot: yield* get(input) } as const; + } + const decoded = yield* decodeRow(row).pipe( + Effect.mapError(mapPersistenceError("ComposerDraftRepository.update:decode")), + ); + const snapshot = toSnapshot(decoded); + yield* PubSub.publish(changes, snapshot); + return { _tag: "accepted", snapshot } as const; + }); + + const subscribe: ComposerDraftRepository["Service"]["subscribe"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const initial = yield* get(input); + return Stream.concat( + Stream.make(initial), + Stream.fromSubscription(subscription).pipe( + Stream.filter((snapshot) => snapshot.threadId === input.threadId), + ), + ); + }), + ); + + return ComposerDraftRepository.of({ get, update, subscribe }); +}); + +export const layer = Layer.effect(ComposerDraftRepository, make); diff --git a/apps/server/src/persistence/ForkMigrations.test.ts b/apps/server/src/persistence/ForkMigrations.test.ts new file mode 100644 index 00000000000..31f49b84e0f --- /dev/null +++ b/apps/server/src/persistence/ForkMigrations.test.ts @@ -0,0 +1,227 @@ +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 ForkMigration0001 from "./ForkMigrations/001_ComposerDrafts.ts"; +import { + forkMigrationEntries, + forkMigrationManifest, + repairLegacyForkMigrationHistory, + runAllMigrations, +} from "./ForkMigrations.ts"; +import { migrationManifest, runMigrations } from "./Migrations.ts"; +import * as NodeSqliteClient from "./NodeSqliteClient.ts"; + +const legacyForkLayer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); +const switchedBuildLayer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); +const upstreamLayer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +legacyForkLayer("ForkMigrations legacy fork upgrade", (it) => { + it.effect("upgrades databases that recorded the composer table as upstream migration 39", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 38 }); + yield* ForkMigration0001; + yield* sql` + INSERT INTO effect_sql_migrations (migration_id, name) + VALUES (39, 'ComposerDrafts') + `; + yield* sql` + INSERT INTO composer_drafts ( + thread_id, + revision, + common_json, + updated_at, + client_mutation_id + ) VALUES ( + 'legacy-draft-thread', + 3, + '{"text":"keep me"}', + '2026-08-09T00:00:00.000Z', + 'legacy-write' + ) + `; + + const result = yield* runAllMigrations(); + + assert.isTrue(result.repairedLegacyHistory); + assert.deepStrictEqual(result.upstream, [[40, "ProjectionProjectFaviconPath"]]); + assert.deepStrictEqual(result.fork, [[2, "WorkspacePortAllocations"]]); + + const upstreamHistory = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id >= 39 + ORDER BY migration_id + `; + assert.deepStrictEqual(upstreamHistory, [ + { + migration_id: 39, + name: "ProjectionProjectsDefaultThreadEnvMode", + }, + { + migration_id: 40, + name: "ProjectionProjectFaviconPath", + }, + ]); + + const forkHistory = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM yngatech_sql_migrations + ORDER BY migration_id + `; + assert.deepStrictEqual(forkHistory, [ + { migration_id: 1, name: "ComposerDrafts" }, + { migration_id: 2, name: "WorkspacePortAllocations" }, + ]); + + const projectColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + assert.includeMembers( + projectColumns.map(({ name }) => name), + ["default_thread_env_mode", "favicon_path"], + ); + + const draftRows = yield* sql<{ + readonly thread_id: string; + readonly revision: number; + readonly common_json: string | null; + }>` + SELECT thread_id, revision, common_json + FROM composer_drafts + WHERE thread_id = 'legacy-draft-thread' + `; + assert.deepStrictEqual(draftRows, [ + { + thread_id: "legacy-draft-thread", + revision: 3, + common_json: '{"text":"keep me"}', + }, + ]); + + const secondRun = yield* runAllMigrations(); + assert.deepStrictEqual(secondRun, { + upstream: [], + fork: [], + repairedLegacyHistory: false, + }); + }), + ); +}); + +switchedBuildLayer("ForkMigrations switched-build upgrade", (it) => { + it.effect("repairs upstream 39 even when a later upstream migration is recorded", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 38 }); + yield* ForkMigration0001; + yield* sql` + INSERT INTO effect_sql_migrations (migration_id, name) + VALUES (39, 'ComposerDrafts'), (40, 'ProjectionProjectFaviconPath') + `; + + const result = yield* runAllMigrations(); + + assert.isTrue(result.repairedLegacyHistory); + assert.deepStrictEqual(result.upstream, []); + const projectColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + assert.include( + projectColumns.map(({ name }) => name), + "default_thread_env_mode", + ); + + const upstreamHistory = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id >= 39 + ORDER BY migration_id + `; + assert.deepStrictEqual(upstreamHistory, [ + { + migration_id: 39, + name: "ProjectionProjectsDefaultThreadEnvMode", + }, + { + migration_id: 40, + name: "ProjectionProjectFaviconPath", + }, + ]); + }), + ); +}); + +upstreamLayer("ForkMigrations canonical upstream upgrade", (it) => { + it.effect("leaves canonical upstream migration 39 untouched", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations(); + assert.isFalse(yield* repairLegacyForkMigrationHistory()); + yield* runAllMigrations(); + + const upstreamMigration = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id = 39 + `; + assert.deepStrictEqual(upstreamMigration, [ + { + migration_id: 39, + name: "ProjectionProjectsDefaultThreadEnvMode", + }, + ]); + + const forkHistory = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM yngatech_sql_migrations + `; + assert.deepStrictEqual(forkHistory, [ + { migration_id: 1, name: "ComposerDrafts" }, + { migration_id: 2, name: "WorkspacePortAllocations" }, + ]); + }), + ); +}); + +it("keeps fork migration IDs sequential from 1", () => { + assert.deepStrictEqual( + forkMigrationEntries.map(([id]) => id), + forkMigrationEntries.map((_, index) => index + 1), + ); +}); + +it("keeps fork migrations out of the upstream manifest", () => { + assert.notInclude( + migrationManifest.map(([, name]) => name as string), + "ComposerDrafts", + ); + assert.notInclude( + migrationManifest.map(([, name]) => name as string), + "WorkspacePortAllocations", + ); + assert.deepStrictEqual(forkMigrationManifest, [ + [1, "ComposerDrafts"], + [2, "WorkspacePortAllocations"], + ]); +}); diff --git a/apps/server/src/persistence/ForkMigrations.ts b/apps/server/src/persistence/ForkMigrations.ts new file mode 100644 index 00000000000..777afc8872b --- /dev/null +++ b/apps/server/src/persistence/ForkMigrations.ts @@ -0,0 +1,124 @@ +import * as Effect from "effect/Effect"; +import * as Migrator from "effect/unstable/sql/Migrator"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import ForkMigration0001 from "./ForkMigrations/001_ComposerDrafts.ts"; +import ForkMigration0002 from "./ForkMigrations/002_WorkspacePortAllocations.ts"; +import UpstreamMigration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; +import { runMigrations } from "./Migrations.ts"; + +const FORK_MIGRATIONS_TABLE = "yngatech_sql_migrations"; +const LEGACY_COMPOSER_DRAFT_MIGRATION_ID = 39; +const LEGACY_COMPOSER_DRAFT_MIGRATION_NAME = "ComposerDrafts"; +const UPSTREAM_MIGRATION_0039_NAME = "ProjectionProjectsDefaultThreadEnvMode"; + +/** + * Fork migrations have their own append-only history so upstream can keep its + * numeric migration sequence unchanged across rebases. + */ +export const forkMigrationEntries = [ + [1, "ComposerDrafts", ForkMigration0001], + [2, "WorkspacePortAllocations", ForkMigration0002], +] as const; + +export const forkMigrationManifest = forkMigrationEntries.map(([id, name]) => [id, name] as const); + +export const makeForkMigrationLoader = (throughId?: number) => + Migrator.fromRecord( + Object.fromEntries( + forkMigrationEntries + .filter(([id]) => throughId === undefined || id <= throughId) + .map(([id, name, migration]) => [`${id}_${name}`, migration]), + ), + ); + +const runFork = Migrator.make({}); + +/** + * Moves the composer-draft migration shipped by the fork out of upstream's + * migration history. Matching both ID and name leaves canonical upstream + * installations untouched. + */ +export const repairLegacyForkMigrationHistory = Effect.fn("repairLegacyForkMigrationHistory")( + function* () { + const sql = yield* SqlClient.SqlClient; + + // Let the stock migrator create its tracking table without running a fork + // migration. The repair and the normal fork pass then share its schema. + yield* runFork({ + loader: makeForkMigrationLoader(0), + table: FORK_MIGRATIONS_TABLE, + }); + + const upstreamMigrationTables = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'effect_sql_migrations' + `; + if (upstreamMigrationTables.length === 0) { + return false; + } + + return yield* sql.withTransaction( + Effect.gen(function* () { + const legacyMigrations = yield* sql<{ readonly migration_id: number }>` + SELECT migration_id + FROM effect_sql_migrations + WHERE migration_id = ${LEGACY_COMPOSER_DRAFT_MIGRATION_ID} + AND name = ${LEGACY_COMPOSER_DRAFT_MIGRATION_NAME} + `; + if (legacyMigrations.length === 0) { + return false; + } + + // The legacy migration created this table. Reapplying its guarded body + // protects databases whose migration record survived an interrupted copy. + yield* ForkMigration0001; + // Apply upstream 39 directly instead of relying on its numeric watermark. + // A user may already have upstream 40 after briefly switching builds. + yield* UpstreamMigration0039; + yield* sql` + INSERT INTO yngatech_sql_migrations (migration_id, name) + VALUES (1, ${LEGACY_COMPOSER_DRAFT_MIGRATION_NAME}) + `; + yield* sql` + UPDATE effect_sql_migrations + SET name = ${UPSTREAM_MIGRATION_0039_NAME} + WHERE migration_id = ${LEGACY_COMPOSER_DRAFT_MIGRATION_ID} + AND name = ${LEGACY_COMPOSER_DRAFT_MIGRATION_NAME} + `; + + return true; + }), + ); + }, +); + +export interface RunForkMigrationsOptions { + readonly toMigrationInclusive?: number | undefined; +} + +export const runForkMigrations = Effect.fn("runForkMigrations")(function* ({ + toMigrationInclusive, +}: RunForkMigrationsOptions = {}) { + const executedMigrations = yield* runFork({ + loader: makeForkMigrationLoader(toMigrationInclusive), + table: FORK_MIGRATIONS_TABLE, + }); + const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`); + yield* migrations.length === 0 + ? Effect.logDebug("Fork database schema is current") + : Effect.log("Fork migrations ran successfully").pipe(Effect.annotateLogs({ migrations })); + return executedMigrations; +}); + +export const runAllMigrations = Effect.fn("runAllMigrations")(function* () { + const repairedLegacyHistory = yield* repairLegacyForkMigrationHistory(); + if (repairedLegacyHistory) { + yield* Effect.log("Moved legacy composer draft migration into fork history"); + } + + const upstream = yield* runMigrations(); + const fork = yield* runForkMigrations(); + return { upstream, fork, repairedLegacyHistory } as const; +}); diff --git a/apps/server/src/persistence/ForkMigrations/001_ComposerDrafts.ts b/apps/server/src/persistence/ForkMigrations/001_ComposerDrafts.ts new file mode 100644 index 00000000000..3fc47c4728e --- /dev/null +++ b/apps/server/src/persistence/ForkMigrations/001_ComposerDrafts.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** Current-state storage for high-churn existing-thread composer drafts. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS composer_drafts ( + thread_id TEXT PRIMARY KEY NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 0), + common_json TEXT, + updated_at TEXT NOT NULL, + client_mutation_id TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/ForkMigrations/002_WorkspacePortAllocations.ts b/apps/server/src/persistence/ForkMigrations/002_WorkspacePortAllocations.ts new file mode 100644 index 00000000000..1d9b6e7833d --- /dev/null +++ b/apps/server/src/persistence/ForkMigrations/002_WorkspacePortAllocations.ts @@ -0,0 +1,14 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** Stable, environment-local port ranges assigned to workspace paths. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS workspace_port_allocations ( + workspace_path TEXT PRIMARY KEY NOT NULL, + base_port INTEGER NOT NULL UNIQUE + CHECK (base_port >= 20000 AND base_port <= 29990 AND base_port % 10 = 0) + ) + `; +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts new file mode 100644 index 00000000000..d46bc8c7a07 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts @@ -0,0 +1,89 @@ +import { assert, it } from "@effect/vitest"; +import { EventId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionThreadActivityRepository } from "../Services/ProjectionThreadActivities.ts"; +import { ProjectionThreadActivityRepositoryLive } from "./ProjectionThreadActivities.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProjectionThreadActivityRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionThreadActivityRepository", (it) => { + it.effect("lists requested and started setup lifecycle rows without a persisted outcome", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadActivityRepository; + const threadId = ThreadId.make("thread-setup-recovery"); + const payload = { + runId: "run-1", + scriptId: "setup", + scriptName: "Setup", + command: "bun install", + terminalId: "setup-setup", + worktreePath: "/repo/worktree", + }; + const append = (id: string, kind: string, sequence: number) => + repository.upsert({ + activityId: EventId.make(id), + threadId, + turnId: null, + tone: "info", + kind, + summary: kind, + payload, + sequence, + createdAt: `2026-01-01T00:00:0${sequence}.000Z`, + }); + + yield* append("requested", "setup-script.requested", 1); + yield* append("started", "setup-script.started", 2); + yield* append("unrelated", "file-edit", 3); + yield* append("completed", "setup-script.completed", 4); + yield* repository.upsert({ + activityId: EventId.make("unfinished-requested"), + threadId, + turnId: null, + tone: "info", + kind: "setup-script.requested", + summary: "setup-script.requested", + payload: { ...payload, runId: "run-2" }, + sequence: 5, + createdAt: "2026-01-01T00:00:05.000Z", + }); + yield* repository.upsert({ + activityId: EventId.make("unfinished-requested-before-start"), + threadId, + turnId: null, + tone: "info", + kind: "setup-script.requested", + summary: "setup-script.requested", + payload: { ...payload, runId: "run-3" }, + sequence: 6, + createdAt: "2026-01-01T00:00:06.000Z", + }); + yield* repository.upsert({ + activityId: EventId.make("unfinished-started"), + threadId, + turnId: null, + tone: "info", + kind: "setup-script.started", + summary: "setup-script.started", + payload: { ...payload, runId: "run-3" }, + sequence: 7, + createdAt: "2026-01-01T00:00:07.000Z", + }); + + const rows = yield* repository.listUnfinishedSetupRuns(); + assert.deepEqual( + rows.map((row) => row.activityId), + [ + EventId.make("unfinished-requested"), + EventId.make("unfinished-requested-before-start"), + EventId.make("unfinished-started"), + ], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 2f4815f9654..d788f0c273a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -106,6 +106,42 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const listUnfinishedSetupRunRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadActivityDbRowSchema, + execute: () => + sql` + SELECT + lifecycle.activity_id AS "activityId", + lifecycle.thread_id AS "threadId", + lifecycle.turn_id AS "turnId", + lifecycle.tone, + lifecycle.kind, + lifecycle.summary, + lifecycle.payload_json AS "payload", + lifecycle.sequence, + lifecycle.created_at AS "createdAt" + FROM projection_thread_activities AS lifecycle + WHERE lifecycle.kind IN ( + 'setup-script.requested', + 'setup-script.started' + ) + AND json_extract(lifecycle.payload_json, '$.runId') IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM projection_thread_activities AS finished + WHERE finished.thread_id = lifecycle.thread_id + AND finished.kind IN ( + 'setup-script.completed', + 'setup-script.failed' + ) + AND json_extract(finished.payload_json, '$.runId') = + json_extract(lifecycle.payload_json, '$.runId') + ) + ORDER BY lifecycle.sequence ASC, lifecycle.created_at ASC, lifecycle.activity_id ASC + `, + }); + const upsert: ProjectionThreadActivityRepositoryShape["upsert"] = (row) => upsertProjectionThreadActivityRow(row).pipe( Effect.mapError( @@ -146,9 +182,34 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { ), ); + const listUnfinishedSetupRuns: ProjectionThreadActivityRepositoryShape["listUnfinishedSetupRuns"] = + () => + listUnfinishedSetupRunRows().pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listUnfinishedSetupRuns:query", + "ProjectionThreadActivityRepository.listUnfinishedSetupRuns:decodeRows", + ), + ), + Effect.map((rows) => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })), + ), + ); + return { upsert, listByThreadId, + listUnfinishedSetupRuns, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index d1e00250126..ccfba1f1b8c 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -5,7 +5,7 @@ import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import type { SqlError } from "effect/unstable/sql/SqlError"; -import { runMigrations } from "../Migrations.ts"; +import { runAllMigrations } from "../ForkMigrations.ts"; import { ServerConfig } from "../../config.ts"; type RuntimeSqliteLayerConfig = { @@ -35,7 +35,7 @@ const setup = Layer.effectDiscard( const sql = yield* SqlClient.SqlClient; yield* sql`PRAGMA foreign_keys = ON;`; yield* sql`PRAGMA journal_mode = WAL;`; - yield* runMigrations(); + yield* runAllMigrations(); }), ); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 47cb6073c47..62ffb609e2c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -67,6 +67,12 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** List setup starts that have no persisted terminal outcome. */ + readonly listUnfinishedSetupRuns: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** * Delete projected thread activity rows by thread. */ diff --git a/apps/server/src/preview/TerminalBrowserOpen.test.ts b/apps/server/src/preview/TerminalBrowserOpen.test.ts new file mode 100644 index 00000000000..e304bb2a2d0 --- /dev/null +++ b/apps/server/src/preview/TerminalBrowserOpen.test.ts @@ -0,0 +1,275 @@ +// @effect-diagnostics nodeBuiltinImport:off - Integration exercises the generated Node browser helper. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { EnvironmentId, ThreadId, type PreviewAutomationRequest } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts"; +import * as TerminalBrowserOpen from "./TerminalBrowserOpen.ts"; + +function listenOnRandomPort(server: NodeHttp.Server | NodeNet.Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Expected a TCP test server")); + return; + } + resolve(address.port); + }); + }); +} + +function closeServer(server: NodeHttp.Server | NodeNet.Server): Promise { + if (!server.listening) return Promise.resolve(); + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +it.layer(NodeServices.layer)("TerminalBrowserOpen", (it) => { + it.effect("installs the helper and rotates terminal-scoped credentials", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-terminal-browser-open-", + }); + const configLayer = ServerConfig.layerTest(process.cwd(), baseDir); + const { browserOpen, broker, config } = yield* Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + return { + browserOpen: yield* TerminalBrowserOpen.make.pipe( + Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), + ), + broker, + config: yield* ServerConfig.ServerConfig, + }; + }).pipe(Effect.provide(configLayer)); + const owner = { + threadId: ThreadId.make("thread-1"), + terminalId: "default", + }; + + const first = yield* browserOpen.register(owner); + const firstToken = first[TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_TOKEN_ENV]; + expect(first.BROWSER).toMatch(/terminal-browser-open\.js$/u); + expect(first[TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_RUNTIME_STATE_ENV]).toBe( + config.serverRuntimeStatePath, + ); + expect(firstToken).toBeTypeOf("string"); + expect(yield* browserOpen.resolve(firstToken ?? "")).toEqual(owner); + expect(yield* fileSystem.readFileString(first.BROWSER ?? "")).toBe( + TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_HELPER_SOURCE, + ); + const shimDir = first[TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_SHIM_DIR_ENV]; + expect(shimDir).toBeTypeOf("string"); + expect(yield* fileSystem.readFileString(NodePath.join(shimDir ?? "", "open"))).toBe( + TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_HELPER_SOURCE, + ); + expect(yield* fileSystem.readFileString(NodePath.join(shimDir ?? "", "xdg-open"))).toBe( + TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_HELPER_SOURCE, + ); + + const second = yield* browserOpen.register(owner); + const secondToken = second[TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_TOKEN_ENV]; + expect(secondToken).not.toBe(firstToken); + expect(yield* browserOpen.resolve(firstToken ?? "")).toBeUndefined(); + expect(yield* browserOpen.resolve(secondToken ?? "")).toEqual(owner); + + let routedRequest: PreviewAutomationRequest | undefined; + const events = yield* broker.connect({ + clientId: "desktop-1", + environmentId: EnvironmentId.make("environment-1"), + }); + yield* Stream.runForEach(events, (event) => { + if (event.type === "connected") return Effect.void; + routedRequest = event.request; + return broker.respond({ + clientId: "desktop-1", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: { available: true }, + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* browserOpen.openInPreview({ + environmentId: EnvironmentId.make("environment-1"), + owner, + url: "http://localhost:5173/app", + }); + expect(routedRequest).toMatchObject({ + threadId: "thread-1", + operation: "open", + input: { url: "http://localhost:5173/app", reuseExistingTab: false }, + presentation: "right-panel", + }); + + yield* browserOpen.unregister(owner); + expect(yield* browserOpen.resolve(secondToken ?? "")).toBeUndefined(); + }), + ); +}); + +it("posts browser intent through the generated OS launcher shim", async () => { + const tempDir = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-terminal-browser-helper-"), + ); + const server = NodeHttp.createServer(); + try { + const receivedRequest = new Promise<{ + readonly authorization: string | undefined; + readonly body: string; + readonly url: string | undefined; + }>((resolve) => { + server.once("request", (request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => { + resolve({ + authorization: request.headers.authorization, + body: Buffer.concat(chunks).toString("utf8"), + url: request.url, + }); + response.writeHead(204).end(); + }); + }); + }); + const serverPort = await listenOnRandomPort(server); + + const helperPath = NodePath.join(tempDir, "open"); + const runtimeStatePath = NodePath.join(tempDir, "server-runtime.json"); + await NodeFSP.writeFile(helperPath, TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_HELPER_SOURCE); + await NodeFSP.writeFile( + runtimeStatePath, + JSON.stringify({ origin: `http://127.0.0.1:${serverPort}` }), + ); + const targetUrl = "http://localhost:5173/app?mode=test"; + const child = NodeChildProcess.spawn(process.execPath, [helperPath, targetUrl], { + env: { + ...process.env, + [TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_RUNTIME_STATE_ENV]: runtimeStatePath, + [TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_TOKEN_ENV]: "terminal-token", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }); + + expect(exitCode, stderr).toBe(0); + expect(await receivedRequest).toEqual({ + authorization: "Bearer terminal-token", + body: JSON.stringify({ url: targetUrl }), + url: TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_PATH, + }); + } finally { + await closeServer(server); + await NodeFSP.rm(tempDir, { recursive: true, force: true }); + } +}); + +it("captures a real Vite+ --open launch through the OS shim", async () => { + const tempDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-vite-open-helper-")); + const callbackServer = NodeHttp.createServer(); + const portReservation = NodeNet.createServer(); + let child: NodeChildProcess.ChildProcess | undefined; + try { + const receivedRequest = new Promise((resolve) => { + callbackServer.once("request", (request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf8")); + response.writeHead(204).end(); + }); + }); + }); + const callbackPort = await listenOnRandomPort(callbackServer); + const devPort = await listenOnRandomPort(portReservation); + await closeServer(portReservation); + + const shimDir = NodePath.join(tempDir, "bin"); + const runtimeStatePath = NodePath.join(tempDir, "server-runtime.json"); + await NodeFSP.mkdir(shimDir); + await Promise.all( + ["open", "xdg-open"].map((launcher) => + NodeFSP.writeFile( + NodePath.join(shimDir, launcher), + TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_HELPER_SOURCE, + { mode: 0o755 }, + ), + ), + ); + await NodeFSP.writeFile(NodePath.join(tempDir, "index.html"), "Vite open test"); + await NodeFSP.writeFile( + runtimeStatePath, + JSON.stringify({ origin: `http://127.0.0.1:${callbackPort}` }), + ); + + const childEnv = { ...process.env }; + delete childEnv.BROWSER; + childEnv.PATH = `${shimDir}:${process.env.PATH ?? ""}`; + childEnv[TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_RUNTIME_STATE_ENV] = runtimeStatePath; + childEnv[TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_TOKEN_ENV] = "terminal-token"; + childEnv[TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_SHIM_DIR_ENV] = shimDir; + child = NodeChildProcess.spawn( + "vp", + ["dev", tempDir, "--host", "127.0.0.1", "--port", String(devPort), "--strictPort", "--open"], + { detached: true, env: childEnv, stdio: ["ignore", "ignore", "pipe"] }, + ); + + let stderr = ""; + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + const exited = new Promise((_resolve, reject) => { + child?.once("error", reject); + child?.once("exit", (code, signal) => { + reject( + new Error( + `Vite+ exited before opening Preview (${String(code)}, ${String(signal)}): ${stderr}`, + ), + ); + }); + }); + const requestBody = await Promise.race([receivedRequest, exited]); + expect(JSON.parse(requestBody)).toEqual({ url: `http://127.0.0.1:${devPort}/` }); + } finally { + if (child?.pid !== undefined) { + const childExited = + child.exitCode === null && child.signalCode === null + ? new Promise((resolve) => child?.once("exit", () => resolve())) + : Promise.resolve(); + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("ESRCH"); + } + await childExited; + } + await closeServer(callbackServer); + await closeServer(portReservation); + await NodeFSP.rm(tempDir, { recursive: true, force: true }); + } +}, 15_000); diff --git a/apps/server/src/preview/TerminalBrowserOpen.ts b/apps/server/src/preview/TerminalBrowserOpen.ts new file mode 100644 index 00000000000..9ae4e9f13dc --- /dev/null +++ b/apps/server/src/preview/TerminalBrowserOpen.ts @@ -0,0 +1,290 @@ +import { + ProviderInstanceId, + ThreadId, + type EnvironmentId, + type PreviewAutomationError, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +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 SynchronizedRef from "effect/SynchronizedRef"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ServerConfig from "../config.ts"; +import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts"; + +export const TERMINAL_BROWSER_OPEN_PATH = "/api/internal/terminal-browser-open"; +export const TERMINAL_BROWSER_OPEN_RUNTIME_STATE_ENV = "T3CODE_TERMINAL_BROWSER_OPEN_RUNTIME_STATE"; +export const TERMINAL_BROWSER_OPEN_TOKEN_ENV = "T3CODE_TERMINAL_BROWSER_OPEN_TOKEN"; +export const TERMINAL_BROWSER_OPEN_SHIM_DIR_ENV = "T3CODE_TERMINAL_BROWSER_OPEN_SHIM_DIR"; + +const HELPER_FILE_NAME = "terminal-browser-open.js"; +const WINDOWS_HELPER_FILE_NAME = "terminal-browser-open.cmd"; +const SHIM_DIRECTORY_NAME = "terminal-browser-open-bin"; +const POSIX_LAUNCHER_NAMES = ["open", "xdg-open"] as const; + +const WINDOWS_HELPER_SOURCE = `@echo off\r +node "%~dp0${HELPER_FILE_NAME}" %*\r +`; + +export const TERMINAL_BROWSER_OPEN_HELPER_SOURCE = `#!/usr/bin/env node +void (async () => { + const args = process.argv.slice(2); + const reversedArgs = [...args].reverse(); + const httpUrl = reversedArgs.find((value) => /^https?:\\/\\//iu.test(value)); + const fallbackTarget = httpUrl ?? reversedArgs.find((value) => !value.startsWith("-")); + if (!fallbackTarget) return; + + const invocationName = (process.argv[1] ?? "").split(/[\\\\/]/u).at(-1)?.toLowerCase(); + const interceptedLauncher = invocationName === "open" || invocationName === "xdg-open"; + const launcherHasOptions = interceptedLauncher && args.some((value) => value.startsWith("-")); + + const fallbackToSystemBrowser = async () => { + const { spawn } = await import("node:child_process"); + const childEnv = { ...process.env }; + const shimDir = process.env.${TERMINAL_BROWSER_OPEN_SHIM_DIR_ENV}; + const pathKey = Object.keys(childEnv).find((key) => key.toUpperCase() === "PATH"); + if (shimDir && pathKey && childEnv[pathKey]) { + const delimiter = process.platform === "win32" ? ";" : ":"; + const normalizedShimDir = process.platform === "win32" ? shimDir.toLowerCase() : shimDir; + childEnv[pathKey] = childEnv[pathKey] + .split(delimiter) + .filter((entry) => { + const normalizedEntry = process.platform === "win32" ? entry.toLowerCase() : entry; + return normalizedEntry !== normalizedShimDir; + }) + .join(delimiter); + } + for (const key of Object.keys(childEnv)) { + const normalized = key.toUpperCase(); + if ( + normalized === "BROWSER" || + normalized === "BROWSER_ARGS" || + normalized === "${TERMINAL_BROWSER_OPEN_RUNTIME_STATE_ENV}" || + normalized === "${TERMINAL_BROWSER_OPEN_TOKEN_ENV}" || + normalized === "${TERMINAL_BROWSER_OPEN_SHIM_DIR_ENV}" + ) { + delete childEnv[key]; + } + } + + let command; + let commandArgs; + if (invocationName === "open") { + command = "/usr/bin/open"; + commandArgs = args; + } else if (invocationName === "xdg-open") { + command = "xdg-open"; + commandArgs = args; + } else if (process.platform === "darwin") { + command = "/usr/bin/open"; + commandArgs = [fallbackTarget]; + } else if (process.platform === "win32") { + const escapedTarget = fallbackTarget.replaceAll("'", "''"); + const encodedCommand = Buffer.from( + "Start-Process '" + escapedTarget + "'", + "utf16le", + ).toString("base64"); + command = "powershell.exe"; + commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand]; + } else { + command = "xdg-open"; + commandArgs = [fallbackTarget]; + } + + const child = spawn(command, commandArgs, { + detached: true, + env: childEnv, + stdio: "ignore", + windowsHide: true, + }); + child.once("error", () => { + process.stderr.write("Unable to open " + fallbackTarget + " in T3 Code or the system browser.\\n"); + }); + child.unref(); + }; + + if (!httpUrl || launcherHasOptions) { + await fallbackToSystemBrowser(); + return; + } + + try { + const runtimeStatePath = process.env.${TERMINAL_BROWSER_OPEN_RUNTIME_STATE_ENV}; + const token = process.env.${TERMINAL_BROWSER_OPEN_TOKEN_ENV}; + if (!runtimeStatePath || !token) throw new Error("T3 browser-open environment is unavailable"); + + const { readFile } = await import("node:fs/promises"); + const runtimeState = JSON.parse(await readFile(runtimeStatePath, "utf8")); + const endpoint = new URL("${TERMINAL_BROWSER_OPEN_PATH}", runtimeState.origin); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 16_000); + let response; + try { + response = await fetch(endpoint, { + method: "POST", + headers: { + authorization: "Bearer " + token, + "content-type": "application/json", + }, + body: JSON.stringify({ url: httpUrl }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + if (!response.ok) throw new Error("T3 browser-open request was rejected"); + } catch { + await fallbackToSystemBrowser(); + } +})(); +`; + +export interface TerminalBrowserOpenOwner { + readonly threadId: string; + readonly terminalId: string; +} + +export interface TerminalBrowserOpenPreviewInput { + readonly environmentId: EnvironmentId; + readonly owner: TerminalBrowserOpenOwner; + readonly url: string; +} + +interface TerminalBrowserOpenState { + readonly ownersByToken: ReadonlyMap; + readonly tokensByOwner: ReadonlyMap; +} + +const ownerKey = (owner: TerminalBrowserOpenOwner): string => + `${owner.threadId}\u0000${owner.terminalId}`; + +export class TerminalBrowserOpen extends Context.Service< + TerminalBrowserOpen, + { + readonly register: (owner: TerminalBrowserOpenOwner) => Effect.Effect>; + readonly unregister: (owner: TerminalBrowserOpenOwner) => Effect.Effect; + readonly resolve: (token: string) => Effect.Effect; + readonly openInPreview: ( + input: TerminalBrowserOpenPreviewInput, + ) => Effect.Effect; + } +>()("t3/preview/TerminalBrowserOpen") {} + +export const make = Effect.gen(function* TerminalBrowserOpenMake() { + const config = yield* ServerConfig.ServerConfig; + const crypto = yield* Crypto.Crypto; + const platform = yield* HostProcessPlatform; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const shimDir = path.join(config.stateDir, SHIM_DIRECTORY_NAME); + const helperPath = path.join(shimDir, HELPER_FILE_NAME); + const windowsHelperPath = path.join(shimDir, WINDOWS_HELPER_FILE_NAME); + const executablePaths = [ + helperPath, + ...POSIX_LAUNCHER_NAMES.map((name) => path.join(shimDir, name)), + ]; + const helperAvailable = yield* Effect.forEach( + executablePaths, + (filePath) => + writeFileStringAtomically({ + filePath, + contents: TERMINAL_BROWSER_OPEN_HELPER_SOURCE, + }).pipe( + Effect.andThen( + fileSystem.chmod(filePath, 0o755).pipe(Effect.orElseSucceed(() => undefined)), + ), + ), + { discard: true }, + ).pipe( + Effect.andThen( + writeFileStringAtomically({ + filePath: windowsHelperPath, + contents: WINDOWS_HELPER_SOURCE, + }), + ), + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("failed to install terminal browser-open helper", { + helperPath, + cause, + }).pipe(Effect.as(false)), + ), + ); + const state = yield* SynchronizedRef.make({ + ownersByToken: new Map(), + tokensByOwner: new Map(), + }); + + const unregister = Effect.fn("TerminalBrowserOpen.unregister")(function* ( + owner: TerminalBrowserOpenOwner, + ) { + yield* SynchronizedRef.update(state, (current) => { + const key = ownerKey(owner); + const token = current.tokensByOwner.get(key); + if (!token) return current; + const ownersByToken = new Map(current.ownersByToken); + const tokensByOwner = new Map(current.tokensByOwner); + ownersByToken.delete(token); + tokensByOwner.delete(key); + return { ownersByToken, tokensByOwner }; + }); + }); + + const register = Effect.fn("TerminalBrowserOpen.register")(function* ( + owner: TerminalBrowserOpenOwner, + ) { + if (!helperAvailable) return {}; + const token = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + yield* SynchronizedRef.update(state, (current) => { + const key = ownerKey(owner); + const previousToken = current.tokensByOwner.get(key); + const ownersByToken = new Map(current.ownersByToken); + const tokensByOwner = new Map(current.tokensByOwner); + if (previousToken) ownersByToken.delete(previousToken); + ownersByToken.set(token, owner); + tokensByOwner.set(key, token); + return { ownersByToken, tokensByOwner }; + }); + return { + BROWSER: platform === "win32" ? windowsHelperPath : helperPath, + [TERMINAL_BROWSER_OPEN_SHIM_DIR_ENV]: shimDir, + [TERMINAL_BROWSER_OPEN_RUNTIME_STATE_ENV]: config.serverRuntimeStatePath, + [TERMINAL_BROWSER_OPEN_TOKEN_ENV]: token, + }; + }); + + const resolve = Effect.fn("TerminalBrowserOpen.resolve")((token: string) => + SynchronizedRef.get(state).pipe(Effect.map((current) => current.ownersByToken.get(token))), + ); + + const openInPreview = Effect.fn("TerminalBrowserOpen.openInPreview")(function* ( + input: TerminalBrowserOpenPreviewInput, + ) { + const issuedAt = yield* Clock.currentTimeMillis; + yield* broker.invoke({ + scope: { + environmentId: input.environmentId, + threadId: ThreadId.make(input.owner.threadId), + providerSessionId: `terminal-browser:${input.owner.threadId}\u0000${input.owner.terminalId}`, + providerInstanceId: ProviderInstanceId.make("t3_terminal"), + capabilities: new Set(["preview"]), + issuedAt, + }, + operation: "open", + input: { url: input.url, reuseExistingTab: false }, + timeoutMs: 15_000, + presentation: "right-panel", + }); + }); + + return TerminalBrowserOpen.of({ register, unregister, resolve, openInPreview }); +}).pipe(Effect.withSpan("TerminalBrowserOpen.make")); + +export const layer = Layer.effect(TerminalBrowserOpen, make); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 5c5da4666b0..e1bff54ede9 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import { + type OrchestrationCommand, + type OrchestrationProject, + EventId, + ProjectId, + ThreadId, + type TerminalEvent, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; @@ -48,32 +58,103 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => }); const makeTerminalManagerLayer = ( - overrides: Pick, + overrides: Pick & + Partial>, ) => Layer.succeed(TerminalManager.TerminalManager, { ...overrides, + open: () => Effect.die(new Error("unused")), attachStream: () => Effect.die(new Error("unused")), + write: () => Effect.die(new Error("unused")), resize: () => Effect.void, clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, - subscribe: () => Effect.succeed(() => undefined), + subscribe: overrides.subscribe ?? (() => Effect.succeed(() => undefined)), subscribeMetadata: () => Effect.succeed(() => undefined), }); const testLayer = ( project: OrchestrationProject, - terminal: Pick, + terminal: Pick & + Partial>, + commands: OrchestrationCommand[] = [], ) => ProjectSetupScriptRunner.layer.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge(makeTerminalManagerLayer(terminal)), + Layer.provideMerge( + Layer.succeed(OrchestrationEngine.OrchestrationEngineService, { + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + readEvents: () => Stream.empty, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectionThreadActivities.ProjectionThreadActivityRepository, { + upsert: () => Effect.void, + listByThreadId: () => Effect.succeed([]), + listUnfinishedSetupRuns: () => Effect.succeed([]), + deleteByThreadId: () => Effect.void, + }), + ), ); describe("ProjectSetupScriptRunner", () => { + it("derives requested or started setup runs without a terminal outcome as unfinished", () => { + const activity = ( + runId: string, + kind: string, + sequence: number, + ): ProjectionThreadActivities.ProjectionThreadActivity => ({ + activityId: EventId.make(`activity-${sequence}`), + threadId: ThreadId.make("thread-1"), + turnId: null, + tone: "info", + kind, + summary: kind, + payload: { + runId, + scriptId: "setup", + scriptName: "Setup", + command: "bun install", + terminalId: "setup-setup", + worktreePath: "/repo/worktrees/a", + }, + sequence, + createdAt: `2026-01-01T00:00:0${sequence}.000Z`, + }); + + const runs = ProjectSetupScriptRunner.deriveUnfinishedSetupRuns([ + activity("requested-only-run", "setup-script.requested", 1), + activity("finished-run", "setup-script.requested", 2), + activity("finished-run", "setup-script.started", 3), + activity("finished-run", "setup-script.completed", 4), + activity("unfinished-run", "setup-script.requested", 5), + activity("unfinished-run", "setup-script.started", 6), + ]); + + expect(runs).toMatchObject([ + { + runId: "requested-only-run", + startedAt: "2026-01-01T00:00:01.000Z", + startedActivityRecorded: false, + }, + { + runId: "unfinished-run", + startedAt: "2026-01-01T00:00:06.000Z", + startedActivityRecorded: true, + }, + ]); + }); + it.effect("returns no-script when no setup script exists", () => { - const open = vi.fn(() => Effect.die("unexpected open")); - const write = vi.fn(() => Effect.die("unexpected write")); + const openCommand = vi.fn(() => Effect.die("unexpected open")); const project = makeProject([]); return Effect.gen(function* () { @@ -85,75 +166,224 @@ describe("ProjectSetupScriptRunner", () => { }); expect(result).toEqual({ status: "no-script" }); - expect(open).not.toHaveBeenCalled(); - expect(write).not.toHaveBeenCalled(); - }).pipe(Effect.provide(testLayer(project, { open, write }))); + expect(openCommand).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(project, { openCommand }))); + }); + + it.effect("opens the deterministic setup terminal with the command as its PTY process", () => { + const commands: OrchestrationCommand[] = []; + const openCommand = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }); + + expect(result).toMatchObject({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + }); + expect(openCommand).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + command: "bun install", + }); + expect( + commands.flatMap((command) => + command.type === "thread.activity.append" ? [command.activity.kind] : [], + ), + ).toEqual(["setup-script.requested", "setup-script.started"]); + }).pipe(Effect.provide(testLayer(project, { openCommand }, commands))); }); - it.effect( - "opens the deterministic setup terminal with worktree env and writes the command", - () => { - const open = vi.fn(() => - Effect.succeed({ + it.effect("records setup command success and all interruptions as failure outcomes", () => { + const commands: OrchestrationCommand[] = []; + let terminalListener: ((event: TerminalEvent) => Effect.Effect) | undefined; + let exitDuringOpen: Extract | null = { + type: "exited", + threadId: "thread-1", + terminalId: "setup-setup", + exitCode: 7, + exitSignal: null, + }; + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + expect(result.status).toBe("started"); + if (!terminalListener) throw new Error("terminal listener was not registered"); + exitDuringOpen = null; + + const activities = commands.flatMap((command) => + command.type === "thread.activity.append" ? [command.activity] : [], + ); + expect(activities.map((activity) => activity.kind)).toEqual([ + "setup-script.requested", + "setup-script.started", + "setup-script.failed", + ]); + expect(activities.at(-1)?.payload).toMatchObject({ + outcome: "failed", + failureReason: "command-exit", + exitCode: 7, + exitSignal: null, + command: "bun install", + }); + + yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + yield* terminalListener({ + type: "exited", + threadId: "thread-1", + terminalId: "setup-setup", + exitCode: 0, + exitSignal: null, + }); + yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + yield* terminalListener({ + type: "closed", + threadId: "thread-1", + terminalId: "setup-setup", + }); + yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + yield* terminalListener({ + type: "restarted", + threadId: "thread-1", + terminalId: "setup-setup", + snapshot: { threadId: "thread-1", terminalId: "setup-setup", cwd: "/repo/worktrees/a", worktreePath: "/repo/worktrees/a", - status: "running" as const, - pid: 123, + status: "running", + pid: 456, history: "", exitCode: null, exitSignal: null, label: "setup-setup", updatedAt: "2026-01-01T00:00:00.000Z", - }), - ); - const write = vi.fn(() => Effect.void); - const project = makeProject([ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, }, - ]); - - return Effect.gen(function* () { - const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - const result = yield* runner.runForThread({ - threadId: "thread-1", - projectCwd: "/repo/project", - worktreePath: "/repo/worktrees/a", - }); + }); - expect(result).toEqual({ - status: "started", - scriptId: "setup", - scriptName: "Setup", - terminalId: "setup-setup", - cwd: "/repo/worktrees/a", - }); - expect(open).toHaveBeenCalledWith({ - threadId: "thread-1", - terminalId: "setup-setup", - cwd: "/repo/worktrees/a", - worktreePath: "/repo/worktrees/a", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + const terminalOutcomes = commands.flatMap((command) => + command.type === "thread.activity.append" && + ["setup-script.completed", "setup-script.failed"].includes(command.activity.kind) + ? [command.activity.kind] + : [], + ); + expect(terminalOutcomes).toEqual([ + "setup-script.failed", + "setup-script.completed", + "setup-script.failed", + "setup-script.failed", + ]); + expect( + commands + .flatMap((command) => + command.type === "thread.activity.append" ? [command.activity] : [], + ) + .at(-1)?.payload, + ).toMatchObject({ + outcome: "failed", + failureReason: "terminal-restarted", + }); + }).pipe( + Effect.provide( + testLayer( + project, + { + openCommand: () => + Effect.gen(function* () { + if (exitDuringOpen && terminalListener) { + yield* terminalListener(exitDuringOpen); + } + return { + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + }), + subscribe: (listener) => + Effect.sync(() => { + terminalListener = listener; + return () => undefined; + }), }, - }); - expect(write).toHaveBeenCalledWith({ - threadId: "thread-1", - terminalId: "setup-setup", - data: "bun install\r", - }); - }).pipe(Effect.provide(testLayer(project, { open, write }))); - }, - ); + commands, + ), + ), + ); + }); it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { + const commands: OrchestrationCommand[] = []; const rootCause = new Error("stat failed"); const terminalError = new TerminalManager.TerminalCwdStatError({ cwd: "/repo/worktrees/a", @@ -190,9 +420,21 @@ describe("ProjectSetupScriptRunner", () => { } }).pipe( Effect.provide( - testLayer(project, { - open: () => Effect.fail(terminalError), - write: () => Effect.die("unexpected write"), + testLayer( + project, + { + openCommand: () => Effect.fail(terminalError), + }, + commands, + ), + ), + Effect.tap(() => + Effect.sync(() => { + expect( + commands.flatMap((command) => + command.type === "thread.activity.append" ? [command.activity.kind] : [], + ), + ).toEqual(["setup-script.requested", "setup-script.failed"]); }), ), ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 41bf0fabf48..9285f1a93c1 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,20 +1,100 @@ -import { ProjectId } from "@t3tools/contracts"; +import { CommandId, EventId, ProjectId, ThreadId } from "@t3tools/contracts"; import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { + increment, + metricAttributes, + setupScriptDuration, + setupScriptRunsTotal, +} from "../observability/Metrics.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; +import { forkParked } from "../serverActivation.ts"; import * as TerminalManager from "../terminal/Manager.ts"; +type SetupRunOutcome = + | { + readonly outcome: "succeeded"; + readonly exitCode: number; + readonly exitSignal: number | null; + } + | { + readonly outcome: "failed"; + readonly reason: + | "command-exit" + | "launch-error" + | "server-restarted" + | "terminal-closed" + | "terminal-error" + | "terminal-restarted"; + readonly exitCode: number | null; + readonly exitSignal: number | null; + readonly detail?: string; + }; + +const SetupRunActivityPayload = Schema.Struct({ + runId: Schema.String, + scriptId: Schema.String, + scriptName: Schema.String, + command: Schema.String, + terminalId: Schema.String, + worktreePath: Schema.String, +}); +const decodeSetupRunActivityPayload = Schema.decodeUnknownOption(SetupRunActivityPayload); + +interface ActiveSetupRun { + readonly runId: string; + readonly threadId: string; + readonly scriptId: string; + readonly scriptName: string; + readonly command: string; + readonly terminalId: string; + readonly worktreePath: string; + readonly startedAt: string; + readonly startedActivityRecorded: boolean; + readonly pendingOutcome: SetupRunOutcome | null; +} + +export function deriveUnfinishedSetupRuns( + activities: ReadonlyArray, +): ReadonlyArray { + const unfinished = new Map(); + for (const activity of activities) { + const payload = decodeSetupRunActivityPayload(activity.payload); + if (Option.isNone(payload)) continue; + const runId = payload.value.runId; + if (activity.kind === "setup-script.requested" || activity.kind === "setup-script.started") { + unfinished.set(runId, { + ...payload.value, + threadId: activity.threadId, + startedAt: activity.createdAt, + startedActivityRecorded: activity.kind === "setup-script.started", + pendingOutcome: null, + }); + continue; + } + unfinished.delete(runId); + } + return [...unfinished.values()]; +} + export interface ProjectSetupScriptRunnerResultNoScript { readonly status: "no-script"; } export interface ProjectSetupScriptRunnerResultStarted { readonly status: "started"; + readonly runId: string; readonly scriptId: string; readonly scriptName: string; readonly terminalId: string; @@ -40,7 +120,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass()); + let nextRunSequence = 0; + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const terminalKey = (threadId: string, terminalId: string) => `${threadId}\0${terminalId}`; + + const appendActivity = Effect.fn("ProjectSetupScriptRunner.appendActivity")(function* (input: { + readonly threadId: string; + readonly runId: string; + readonly kind: string; + readonly summary: string; + readonly tone: "info" | "error"; + readonly createdAt: string; + readonly payload: Record; + }) { + const activityId = EventId.make(`setup:${input.runId}:${input.kind}`); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(`server:setup-script:${input.runId}:${input.kind}`), + threadId: ThreadId.make(input.threadId), + activity: { + id: activityId, + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + + const recordOutcome = Effect.fn("ProjectSetupScriptRunner.recordOutcome")(function* ( + run: ActiveSetupRun, + outcome: SetupRunOutcome, + ) { + const finishedAt = yield* nowIso; + const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(run.startedAt)); + const succeeded = outcome.outcome === "succeeded"; + const interrupted = + outcome.outcome === "failed" && + (outcome.reason === "server-restarted" || + outcome.reason === "terminal-closed" || + outcome.reason === "terminal-restarted"); + yield* appendActivity({ + threadId: run.threadId, + runId: run.runId, + kind: succeeded ? "setup-script.completed" : "setup-script.failed", + summary: succeeded + ? "Setup script completed" + : interrupted + ? "Setup script stopped" + : "Setup script failed", + tone: succeeded ? "info" : "error", + createdAt: finishedAt, + payload: { + runId: run.runId, + scriptId: run.scriptId, + scriptName: run.scriptName, + command: run.command, + terminalId: run.terminalId, + worktreePath: run.worktreePath, + outcome: outcome.outcome, + ...(outcome.outcome === "failed" ? { failureReason: outcome.reason } : {}), + exitCode: outcome.exitCode, + exitSignal: outcome.exitSignal, + durationMs, + ...(outcome.outcome === "failed" && outcome.detail !== undefined + ? { detail: outcome.detail } + : {}), + }, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to record setup script outcome", { + threadId: run.threadId, + runId: run.runId, + outcome: outcome.outcome, + cause, + }), + ), + ); + yield* increment(setupScriptRunsTotal, { outcome: outcome.outcome }); + yield* Metric.update( + Metric.withAttributes(setupScriptDuration, metricAttributes({ outcome: outcome.outcome })), + Duration.millis(durationMs), + ); + }); + + const finishRun = Effect.fn("ProjectSetupScriptRunner.finishRun")(function* ( + threadId: string, + terminalId: string, + outcome: SetupRunOutcome, + ) { + const ready = yield* SynchronizedRef.modify(activeRunsRef, (runs) => { + const key = terminalKey(threadId, terminalId); + const run = runs.get(key); + if (!run) return [Option.none(), runs] as const; + if (!run.startedActivityRecorded) { + const next = new Map(runs); + next.set(key, { ...run, pendingOutcome: outcome }); + return [Option.none(), next] as const; + } + const next = new Map(runs); + next.delete(key); + return [Option.some(run), next] as const; + }); + if (Option.isSome(ready)) { + yield* recordOutcome(ready.value, outcome); + } + }); + + const unsubscribe = yield* terminalManager.subscribe((event) => { + switch (event.type) { + case "exited": + return finishRun( + event.threadId, + event.terminalId, + event.exitCode === 0 && (event.exitSignal === null || event.exitSignal === 0) + ? { + outcome: "succeeded", + exitCode: event.exitCode, + exitSignal: event.exitSignal, + } + : { + outcome: "failed", + reason: "command-exit", + exitCode: event.exitCode, + exitSignal: event.exitSignal, + }, + ); + case "closed": + return finishRun(event.threadId, event.terminalId, { + outcome: "failed", + reason: "terminal-closed", + exitCode: null, + exitSignal: null, + }); + case "error": + return finishRun(event.threadId, event.terminalId, { + outcome: "failed", + reason: "terminal-error", + exitCode: null, + exitSignal: null, + detail: event.message, + }); + case "restarted": + return finishRun(event.threadId, event.terminalId, { + outcome: "failed", + reason: "terminal-restarted", + exitCode: null, + exitSignal: null, + }); + default: + return Effect.void; + } + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + const recoverInterruptedRuns = Effect.fn("ProjectSetupScriptRunner.recoverInterruptedRuns")( + function* () { + const activities = yield* projectionThreadActivities.listUnfinishedSetupRuns(); + yield* Effect.forEach( + deriveUnfinishedSetupRuns(activities), + (run) => + recordOutcome(run, { + outcome: "failed", + reason: "server-restarted", + exitCode: null, + exitSignal: null, + }), + { concurrency: 1, discard: true }, + ); + }, + Effect.catchCause((cause) => + Effect.logWarning("failed to recover interrupted setup script runs", { cause }), + ), + ); + yield* forkParked(recoverInterruptedRuns()); const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", @@ -133,18 +395,56 @@ export const make = Effect.gen(function* () { const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; const cwd = input.worktreePath; + const requestedAt = yield* nowIso; + nextRunSequence += 1; + const runId = `${input.threadId}:${script.id}:${requestedAt}:${nextRunSequence}`; const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }); - yield* terminalManager - .open({ + const activeRun: ActiveSetupRun = { + runId, + threadId: input.threadId, + scriptId: script.id, + scriptName: script.name, + command: script.command, + terminalId, + worktreePath: input.worktreePath, + startedAt: requestedAt, + startedActivityRecorded: false, + pendingOutcome: null, + }; + yield* SynchronizedRef.update(activeRunsRef, (runs) => { + const next = new Map(runs); + next.set(terminalKey(input.threadId, terminalId), activeRun); + return next; + }); + yield* appendActivity({ + threadId: input.threadId, + runId, + kind: "setup-script.requested", + summary: "Starting setup script", + tone: "info", + createdAt: requestedAt, + payload: { + runId, + scriptId: script.id, + scriptName: script.name, + command: script.command, + terminalId, + worktreePath: input.worktreePath, + }, + }).pipe(Effect.ignoreCause({ log: true })); + + const terminal = yield* terminalManager + .openCommand({ threadId: input.threadId, terminalId, cwd, worktreePath: input.worktreePath, env, + command: script.command, }) .pipe( Effect.mapError( @@ -155,26 +455,84 @@ export const make = Effect.gen(function* () { cause, }), ), - ); - yield* terminalManager - .write({ - threadId: input.threadId, - terminalId, - data: `${script.command}\r`, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "writeCommand", - cause, - }), + Effect.tapError((error) => + SynchronizedRef.update(activeRunsRef, (runs) => { + const next = new Map(runs); + next.delete(terminalKey(input.threadId, terminalId)); + return next; + }).pipe( + Effect.andThen( + recordOutcome(activeRun, { + outcome: "failed", + reason: "launch-error", + exitCode: null, + exitSignal: null, + detail: error.message, + }), + ), + ), ), ); + if (terminal.status === "error") { + const startError = new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause: new Error(`Setup terminal '${terminalId}' failed to start.`), + }); + yield* SynchronizedRef.update(activeRunsRef, (runs) => { + const next = new Map(runs); + next.delete(terminalKey(input.threadId, terminalId)); + return next; + }); + yield* recordOutcome(activeRun, { + outcome: "failed", + reason: "launch-error", + exitCode: null, + exitSignal: null, + detail: startError.message, + }); + return yield* startError; + } + + const startedAt = yield* nowIso; + yield* appendActivity({ + threadId: input.threadId, + runId, + kind: "setup-script.started", + summary: "Setup script started", + tone: "info", + createdAt: startedAt, + payload: { + runId, + scriptId: script.id, + scriptName: script.name, + command: script.command, + terminalId, + worktreePath: input.worktreePath, + }, + }).pipe(Effect.ignoreCause({ log: true })); + + const pendingOutcome = yield* SynchronizedRef.modify(activeRunsRef, (runs) => { + const key = terminalKey(input.threadId, terminalId); + const run = runs.get(key); + if (!run) return [Option.none(), runs] as const; + if (run.pendingOutcome) { + const next = new Map(runs); + next.delete(key); + return [Option.some([run, run.pendingOutcome] as const), next] as const; + } + const next = new Map(runs); + next.set(key, { ...run, startedActivityRecorded: true }); + return [Option.none(), next] as const; + }); + if (Option.isSome(pendingOutcome)) { + yield* recordOutcome(...pendingOutcome.value); + } + return { status: "started", + runId, scriptId: script.id, scriptName: script.name, terminalId, diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index a997459e63d..1351dd369ac 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -109,7 +109,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); - it.effect("prefers upstream over origin when both remotes are configured", () => + it.effect("uses the remote selected as gh's default repository", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ @@ -119,14 +119,47 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + yield* git(cwd, ["config", "remote.origin.gh-resolved", "pingdotgg/t3code"]); const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); expect(identity).not.toBeNull(); - expect(identity?.locator.remoteName).toBe("upstream"); - expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(identity?.displayName).toBe("t3tools/t3code"); + expect(identity?.locator.remoteName).toBe("origin"); + expect(identity?.canonicalKey).toBe("github.com/pingdotgg/t3code"); + expect(identity?.displayName).toBe("pingdotgg/t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + + it.effect("follows branch remote changes before gh's selected default", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-branch-remote-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["checkout", "-b", "feature/branch-target"]); + yield* git(cwd, ["config", "user.email", "test@example.com"]); + yield* git(cwd, ["config", "user.name", "Test User"]); + yield* git(cwd, ["commit", "--allow-empty", "-m", "Initial commit"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + yield* git(cwd, ["remote", "add", "fork", "git@github.com:julius/t3code.git"]); + yield* git(cwd, ["config", "remote.origin.gh-resolved", "base"]); + yield* git(cwd, ["config", "branch.feature/branch-target.remote", "fork"]); + yield* git(cwd, ["config", "branch.feature/branch-target.merge", "refs/heads/main"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity?.locator.remoteName).toBe("fork"); + expect(identity?.canonicalKey).toBe("github.com/julius/t3code"); + + yield* git(cwd, ["checkout", "-b", "feature/no-branch-target"]); + + const fallbackIdentity = yield* resolver.resolve(cwd); + expect(fallbackIdentity?.locator.remoteName).toBe("origin"); + expect(fallbackIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 50608e7704c..a3dbaf19576 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -2,6 +2,7 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, normalizeGitRemoteUrl, + parseGitRemoteConfig, } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -15,6 +16,7 @@ import * as ProcessRunner from "../processRunner.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); +const CACHE_KEY_SEPARATOR = "\0"; export interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; @@ -29,26 +31,42 @@ export class RepositoryIdentityResolver extends Context.Service< } >()("t3/project/RepositoryIdentityResolver") {} -function parseRemoteFetchUrls(stdout: string): Map { +function parseRemoteConfig(stdout: string): { + readonly remotes: ReadonlyMap; + readonly ghDefaultRemote: { + readonly remoteName: string; + readonly repositoryPath: string | null; + } | null; +} { + const entries = parseGitRemoteConfig(stdout); const remotes = new Map(); - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); - if (!match) continue; - const [, remoteName = "", remoteUrl = "", direction = ""] = match; - if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { - continue; - } - remotes.set(remoteName, remoteUrl); + for (const entry of entries) { + if (entry.url) remotes.set(entry.remoteName, entry.url); } - return remotes; + + const pinned = entries.find((entry) => entry.ghResolved !== null); + const ghDefaultRemote = pinned?.ghResolved + ? { + remoteName: pinned.remoteName, + repositoryPath: pinned.ghResolved === "base" ? null : pinned.ghResolved.toLowerCase(), + } + : null; + + return { remotes, ghDefaultRemote }; +} + +function parseCurrentBranchRemoteName(stdout: string): string | null { + const current = stdout.split("\n").find((line) => line.startsWith("*\t")); + const remoteName = current?.slice(2).trim() ?? ""; + return remoteName.length > 0 ? remoteName : null; } function pickPrimaryRemote( remotes: ReadonlyMap, + preferredRemoteNames: ReadonlyArray, ): { readonly remoteName: string; readonly remoteUrl: string } | null { - for (const preferredRemoteName of ["upstream", "origin"] as const) { + for (const preferredRemoteName of preferredRemoteNames) { + if (preferredRemoteName === null) continue; const remoteUrl = remotes.get(preferredRemoteName); if (remoteUrl) { return { remoteName: preferredRemoteName, remoteUrl }; @@ -63,9 +81,15 @@ function pickPrimaryRemote( function buildRepositoryIdentity(input: { readonly remoteName: string; readonly remoteUrl: string; + readonly repositoryPath?: string; readonly rootPath: string; }): RepositoryIdentity { - const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); + const remoteCanonicalKey = normalizeGitRemoteUrl(input.remoteUrl); + const remoteHost = remoteCanonicalKey.split("/")[0] ?? ""; + const canonicalKey = + input.repositoryPath && remoteHost + ? `${remoteHost}/${input.repositoryPath}` + : remoteCanonicalKey; const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); const repositoryPath = canonicalKey.split("/").slice(1).join("/"); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); @@ -90,7 +114,7 @@ function buildRepositoryIdentity(input: { const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; + let rootPath = cwd; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -101,16 +125,29 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); - if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; + if (topLevelResult._tag === "Some" && topLevelResult.value.code === 0) { + rootPath = topLevelResult.value.stdout.trim() || cwd; } - const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } + const branchRemoteResult = yield* processRunner + .run({ + command: "git", + args: [ + "-C", + rootPath, + "for-each-ref", + "--format=%(HEAD)%09%(upstream:remotename)", + "refs/heads", + ], + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + const branchRemoteName = + branchRemoteResult._tag === "Some" && branchRemoteResult.value.code === 0 + ? parseCurrentBranchRemoteName(branchRemoteResult.value.stdout) + : null; - return cacheKey; + return `${rootPath}${CACHE_KEY_SEPARATOR}${branchRemoteName ?? ""}`; }, ); @@ -120,19 +157,37 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( cacheKey: string, ): Effect.fn.Return { const processRunner = yield* ProcessRunner.ProcessRunner; - const remoteResult = yield* processRunner + const [rootPath = cacheKey, branchRemoteName = ""] = cacheKey.split(CACHE_KEY_SEPARATOR); + const remoteConfigResult = yield* processRunner .run({ command: "git", - args: ["-C", cacheKey, "remote", "-v"], + args: ["-C", rootPath, "config", "--get-regexp", "^remote\\..*\\.(url|gh-resolved)$"], timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); - if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { + if (remoteConfigResult._tag === "None" || remoteConfigResult.value.code !== 0) { return null; } - const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); - return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; + const { remotes, ghDefaultRemote } = parseRemoteConfig(remoteConfigResult.value.stdout); + const remote = pickPrimaryRemote(remotes, [ + branchRemoteName || null, + ghDefaultRemote?.remoteName ?? null, + "upstream", + "origin", + ]); + if (!remote) return null; + + const usesBranchRemote = branchRemoteName.length > 0 && remotes.has(branchRemoteName); + const repositoryPath = + !usesBranchRemote && remote.remoteName === ghDefaultRemote?.remoteName + ? (ghDefaultRemote.repositoryPath ?? undefined) + : undefined; + return buildRepositoryIdentity({ + ...remote, + rootPath, + ...(repositoryPath ? { repositoryPath } : {}), + }); }); export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b1fa74e81bd..858e781f384 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4090,6 +4090,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(ultracode ? { ultracode: true } : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const sessionEnvironment = { + ...claudeEnvironment, + ...input.environment, + }; // The attachments dir grant lets the agent Read/copy pasted images at // the paths ProviderService injects into the turn text, without an // approval prompt. It is a leaf directory holding only attachment @@ -4120,7 +4124,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, - env: claudeEnvironment, + env: sessionEnvironment, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 5358716aabe..3b37fdf63bd 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -513,6 +513,91 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("ignores empty command terminal interactions", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-terminal-interaction"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/commandExecution/terminalInteraction", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("exec-1"), + payload: { + itemId: "exec-1", + processId: "1234", + stdin: "", + threadId: "thread-1", + turnId: "turn-1", + }, + } satisfies ProviderEvent); + yield* runtime.emit({ + id: asEventId("evt-plan-delta-after-terminal-interaction"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + method: "item/plan/delta", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("plan-1"), + payload: { + itemId: "plan-1", + delta: "still running", + threadId: "thread-1", + turnId: "turn-1", + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.eventId, "evt-plan-delta-after-terminal-interaction"); + NodeAssert.equal(firstEvent.value.type, "turn.proposed.delta"); + }), + ); + + it.effect("maps meaningful terminal interactions without stdin", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-terminal-interrupt"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/commandExecution/terminalInteraction", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("exec-1"), + payload: { + itemId: "exec-1", + processId: "1234", + stdin: "\u0003", + threadId: "thread-1", + turnId: "turn-1", + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "command.interaction"); + if (firstEvent.value.type !== "command.interaction") { + return; + } + NodeAssert.deepStrictEqual(firstEvent.value.payload, { interaction: "ctrl_c" }); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => 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 065156d3647..b53ecf97dd9 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -42,6 +42,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { classifyCommandInteraction } from "../../CommandInteraction.ts"; import { ProviderAdapterRequestError, @@ -1133,17 +1134,31 @@ function mapToRuntimeEvents( return completed ? [completed] : []; } - if ( - event.method === "item/reasoning/summaryPartAdded" || - event.method === "item/commandExecution/terminalInteraction" - ) { + if (event.method === "item/commandExecution/terminalInteraction") { + const payload = readPayload(EffectCodexSchema.V2TerminalInteractionNotification, event.payload); + if (!payload) { + return []; + } + const interaction = classifyCommandInteraction(payload.stdin); + if (!interaction) { + return []; + } + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "command.interaction", + payload: { interaction }, + }, + ]; + } + + if (event.method === "item/reasoning/summaryPartAdded") { return [ { ...runtimeEventBase(event, canonicalThreadId), type: "item.updated", payload: { - itemType: - event.method === "item/reasoning/summaryPartAdded" ? "reasoning" : "command_execution", + itemType: "reasoning", ...(event.payload !== undefined ? { data: event.payload } : {}), }, }, @@ -1662,14 +1677,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; + const sessionEnvironment = input.environment + ? { ...(options?.environment ?? process.env), ...input.environment } + : options?.environment; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, - launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), - ...(options?.environment ? { environment: options.environment } : {}), + launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, sessionEnvironment), + ...(sessionEnvironment ? { environment: sessionEnvironment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } @@ -1682,7 +1700,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(mcpSession ? { environment: { - ...(options?.environment ?? process.env), + ...(sessionEnvironment ?? process.env), T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), }, appServerArgs: [ diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 30c173d8fae..89fe084f7c0 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -513,6 +513,9 @@ export function makeCursorAdapter( let ctx!: CursorSessionContext; const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; + const sessionEnvironment = input.environment + ? { ...(options?.environment ?? process.env), ...input.environment } + : options?.environment; const acpNativeLoggers = makeAcpNativeLoggers({ nativeEventLogger, provider: PROVIDER, @@ -534,7 +537,7 @@ export function makeCursorAdapter( const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), + ...(sessionEnvironment ? { environment: sessionEnvironment } : {}), childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 858d862e6d5..25ab0a16a98 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -563,6 +563,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const resumeSessionId = parseGrokResume(input.resumeCursor)?.sessionId; + const sessionEnvironment = input.environment + ? { ...(options?.environment ?? process.env), ...input.environment } + : options?.environment; const acpNativeLoggers = makeAcpNativeLoggers({ nativeEventLogger, provider: PROVIDER, @@ -572,7 +575,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, - ...(options?.environment ? { environment: options.environment } : {}), + ...(sessionEnvironment ? { environment: sessionEnvironment } : {}), childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7..237e0f58415 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1216,6 +1216,9 @@ export function makeOpenCodeAdapter( const started = yield* Effect.gen(function* () { const sessionScope = yield* Scope.make(); + const sessionEnvironment = input.environment + ? { ...(options?.environment ?? process.env), ...input.environment } + : options?.environment; const startedExit = yield* Effect.exit( Effect.gen(function* () { // The runtime binds the server's lifetime to the Scope.Scope @@ -1224,7 +1227,7 @@ export function makeOpenCodeAdapter( const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, serverUrl, - ...(options?.environment ? { environment: options.environment } : {}), + ...(sessionEnvironment ? { environment: sessionEnvironment } : {}), }); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 7334cd01972..1d1134f1b75 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -292,7 +292,9 @@ function makeProviderServiceLayer() { const layer = it.layer( Layer.mergeAll( - makeProviderServiceLive().pipe( + makeProviderServiceLive({ + resolveWorkspaceEnvironment: () => Effect.succeed({ T3CODE_WORKSPACE_PORT: "24120" }), + }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -928,11 +930,13 @@ routing.layer("ProviderServiceLive routing", (it) => { const startPayload = resumedStartInput as { provider?: string; cwd?: string; + environment?: NodeJS.ProcessEnv; resumeCursor?: unknown; threadId?: string; }; assert.equal(startPayload.provider, "codex"); assert.equal(startPayload.cwd, "/tmp/project"); + assert.equal(startPayload.environment?.T3CODE_WORKSPACE_PORT, "24120"); assert.deepEqual(startPayload.resumeCursor, session.resumeCursor); assert.equal(startPayload.threadId, session.threadId); } diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2ac00873df9..5e515f24649 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -57,6 +57,7 @@ import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; +import * as WorkspacePortAllocator from "../../workspace/WorkspacePortAllocator.ts"; const isModelSelection = Schema.is(ModelSelection); /** @@ -66,6 +67,9 @@ const isModelSelection = Schema.is(ModelSelection); */ export interface ProviderServiceLiveOptions { readonly canonicalEventLogger?: EventNdjsonLogger; + readonly resolveWorkspaceEnvironment?: ( + workspacePath: string, + ) => Effect.Effect, WorkspacePortAllocator.WorkspacePortAllocationError>; } type ProviderServiceMethod = @@ -207,11 +211,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const analytics = yield* Effect.service(AnalyticsService.AnalyticsService); const serverConfig = yield* ServerConfig.ServerConfig; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const workspacePortAllocator = yield* Effect.serviceOption( + WorkspacePortAllocator.WorkspacePortAllocator, + ); // Options-provided logger wins (test overrides); otherwise we take whatever // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical // log writer is attached", which downstream code already handles as a // no-op. const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical; + const resolveWorkspaceEnvironment: ( + workspacePath: string, + ) => Effect.Effect, WorkspacePortAllocator.WorkspacePortAllocationError> = + options?.resolveWorkspaceEnvironment ?? + (Option.isSome(workspacePortAllocator) + ? workspacePortAllocator.value.environmentFor + : (_workspacePath: string) => Effect.succeed>({})); const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; @@ -399,6 +413,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + const workspaceEnvironment = persistedCwd + ? yield* resolveWorkspaceEnvironment(persistedCwd).pipe( + Effect.mapError((cause) => toValidationError(input.operation, cause.message, cause)), + ) + : undefined; yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); const resumed = yield* adapter @@ -407,6 +426,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( provider: input.binding.provider, providerInstanceId: bindingInstanceId, ...(persistedCwd ? { cwd: persistedCwd } : {}), + ...(workspaceEnvironment ? { environment: workspaceEnvironment } : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", @@ -576,6 +596,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( (persistedBinding?.providerInstanceId === resolvedInstanceId ? readPersistedCwd(persistedBinding.runtimePayload) : undefined); + const workspaceEnvironment = effectiveCwd + ? yield* resolveWorkspaceEnvironment(effectiveCwd).pipe( + Effect.mapError((cause) => + toValidationError("ProviderService.startSession", cause.message, cause), + ), + ) + : undefined; yield* Effect.annotateCurrentSpan({ "provider.kind": resolvedProvider, "provider.resume_cursor.source": @@ -602,6 +629,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...input, providerInstanceId: resolvedInstanceId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(workspaceEnvironment !== undefined ? { environment: workspaceEnvironment } : {}), ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), }) .pipe(Effect.onError(() => clearMcpSession(threadId))); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd..2ae7087e425 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -23,6 +23,11 @@ import type { import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; +export interface ProviderSessionLaunchInput extends ProviderSessionStartInput { + /** Server-authored variables scoped to the session's workspace. */ + readonly environment?: NodeJS.ProcessEnv; +} + export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; export interface ProviderAdapterCapabilities { @@ -53,7 +58,7 @@ export interface ProviderAdapterShape { * Start a provider-backed session. */ readonly startSession: ( - input: ProviderSessionStartInput, + input: ProviderSessionLaunchInput, ) => Effect.Effect; /** diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index d1af03db9d7..8a77d16b16c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1548,6 +1548,70 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("issues a browser-readable URL for a file at the pull request head", () => + Effect.gen(function* () { + const url = + "https://raw.githubusercontent.com/acme/web/refs/pull/7/head/docs/screenshot.png?token=file-token"; + mockedExecute.mockReturnValueOnce(Effect.succeed(output(`{"url":"${url}","size":83785}`))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + expect( + yield* cli.getPullRequestFile({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + path: "docs/screenshots/hello world.png", + }), + ).toEqual({ url, size: 83_785 }); + + expect(callAt(0).args).toContain( + "repos/acme/web/contents/docs/screenshots/hello%20world.png?ref=refs%2Fpull%2F7%2Fhead", + ); + expect(callAt(0).args).toContain("{ url: .download_url, size: .size }"); + }), + ); + + it.effect("rejects a contents response without a download URL", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestFile({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + path: "docs/screenshot.png", + }), + ); + + assert.strictEqual(error._tag, "GitHubPullRequestFileUrlUnavailableError"); + }), + ); + + it.effect("rejects an oversized pull request image before proxying it", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output('{"url":"https://raw.example/large.png","size":10485761}')), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestFile({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + path: "docs/large.png", + }), + ); + + assert.strictEqual(error._tag, "GitHubPullRequestFileUrlUnavailableError"); + }), + ); + it.effect("ends the diff on a page with no files rather than asking for it again", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); @@ -2139,7 +2203,9 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, }); - assert.strictEqual(mockedExecute.mock.calls.length, 10); + // Twenty-five threads per deliberately narrow query, up to the same thousand-thread + // traversal ceiling the former hundred-row query provided. + assert.strictEqual(mockedExecute.mock.calls.length, 40); assert.isTrue(conversation.truncated); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 27402c2115a..3855be4ef89 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { @@ -197,6 +198,24 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedErrorCl } } +/** The contents endpoint answered, but did not provide a browser-readable file URL. */ +export class GitHubPullRequestFileUrlUnavailableError extends Schema.TaggedErrorClass()( + "GitHubPullRequestFileUrlUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + path: Schema.String, + }, +) { + get detail(): string { + return `The pull request file '${this.path}' reported no usable download URL.`; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestFile: ${this.detail}`; + } +} + /** * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a @@ -245,6 +264,7 @@ export type GitHubPullRequestCliError = | GitHubDiffCommitError | GitHubDiffRevisionsUnavailableError | GitHubDiffFileContentsUnavailableError + | GitHubPullRequestFileUrlUnavailableError | GitHubRepositorySelectorError | GitHubSubjectScopeError | GitHubViewerLoginUnavailableError; @@ -254,6 +274,15 @@ const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; const DIFF_TIMEOUT_MS = 60_000; /** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +/** Repository images are shown inline, not used as a path for serving arbitrary large blobs. */ +const PULL_REQUEST_FILE_MAX_BYTES = 10 * 1024 * 1024; +const GitHubPullRequestFileJson = Schema.fromJsonString( + Schema.Struct({ + url: Schema.URLFromString, + size: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: PULL_REQUEST_FILE_MAX_BYTES })), + }), +); +const decodePullRequestFileJson = Schema.decodeUnknownOption(GitHubPullRequestFileJson); /** A search-free fallback may scan older rows for local filters, but never the whole repository. */ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; @@ -263,10 +292,14 @@ const DIFF_FILES_PAGE_SIZE = 100; /** * Pages of review threads to follow before the conversation is reported as truncated. GitHub - * serves a hundred threads a page, so this is a thousand threads — past anything a pull request - * a person is reading has, and short of walking a repository-sized conversation forever. + * serves twenty-five threads in the deliberately narrow initial query, so forty pages retain the + * previous thousand-thread ceiling — past anything a person is reading, and short of walking a + * repository-sized conversation forever. */ -const REVIEW_THREAD_PAGES = 10; +const REVIEW_THREAD_PAGES = 40; + +/** Dismissals still use GitHub's hundred-row page, so ten pages retain the same ceiling. */ +const REVIEW_DISMISSAL_PAGES = 10; /** * And pages of one thread's own comments, for the rare thread longer than a single page. A @@ -419,6 +452,14 @@ export class GitHubPullRequestCli extends Context.Service< GitHubPullRequestCliError >; + readonly getPullRequestFile: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly path: string; + }) => Effect.Effect<{ readonly url: string; readonly size: number }, GitHubPullRequestCliError>; + readonly listReviewThreadComments: (input: { readonly cwd: string; readonly repository: string; @@ -1136,6 +1177,40 @@ export const make = Effect.gen(function* () { return { oldContents, newContents }; }); + const getPullRequestFile: GitHubPullRequestCli["Service"]["getPullRequestFile"] = (input) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const result = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/contents/${input.path + .split("/") + .map(encodeURIComponent) + .join("/")}?ref=${encodeURIComponent(`refs/pull/${input.number}/head`)}`, + "--jq", + "{ url: .download_url, size: .size }", + ], + maxOutputBytes: 8_192, + timeoutMs: DIFF_TIMEOUT_MS, + }); + const decodedFile = decodePullRequestFileJson(result.stdout.trim()); + if ( + result.stdoutTruncated || + Option.isNone(decodedFile) || + (decodedFile.value.url.protocol !== "https:" && decodedFile.value.url.protocol !== "http:") + ) { + return yield* new GitHubPullRequestFileUrlUnavailableError({ + command: "gh", + cwd: input.cwd, + path: input.path, + }); + } + return { url: decodedFile.value.url.toString(), size: decodedFile.value.size }; + }); + return GitHubPullRequestCli.of({ getViewerLogin: (input) => github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( @@ -1438,6 +1513,8 @@ export const make = Effect.gen(function* () { getPullRequestDiffFileContents, + getPullRequestFile, + listReviewThreadComments: (input) => Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); @@ -1517,7 +1594,7 @@ export const make = Effect.gen(function* () { // ordinarily accrues. Followed so a review whose event fell past that page still finds // its reason. let dismissalPage = 0; - while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) { + while (dismissalCursor !== null && dismissalPage < REVIEW_DISMISSAL_PAGES) { const read: { readonly dismissalsByReviewId: ReadonlyMap; readonly nextCursor: string | null; diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 57d18e8ab91..28bf4ee3369 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -390,6 +390,8 @@ export const make = Effect.gen(function* () { getDiffFileContents: (input) => cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), + getFile: (input) => cli.getPullRequestFile(input).pipe(Effect.mapError(fail("getFile"))), + listReviewerCandidates: (input) => cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 6356d593b95..7ab5e7d8429 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -193,6 +193,12 @@ export interface ProviderDiffFileContents { readonly newContents: string; } +/** A bounded file at the pull request's current head, ready for the server to proxy. */ +export interface ProviderPullRequestFile { + readonly url: string; + readonly size: number; +} + export interface ProviderRepositoryRef { readonly cwd: string; /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ @@ -338,6 +344,18 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** + * A short-lived URL for a repository file at the change request's head. Optional because not + * every host can exchange its CLI authentication for a file-scoped URL the server can proxy. + * GitHub uses this for private repository images embedded in descriptions. + */ + readonly getFile?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly path: string; + }, + ) => Effect.Effect; + readonly runAction: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 456a5023b16..81cbb5911f9 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -14,6 +14,7 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr import { PullRequestProviderError, type ProviderChangeRequest, + type ProviderChangeRequestDetail, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; @@ -75,6 +76,26 @@ function changeRequest(number: number, updatedAt: string): ProviderChangeRequest }; } +function changeRequestDetail(number: number): ProviderChangeRequestDetail { + return { + ...changeRequest(number, "2026-07-02T00:00:00Z"), + body: "Ready before the conversation", + changedFiles: 2, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + }; +} + function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { return new PullRequestProviderError({ provider, @@ -174,6 +195,55 @@ function makeService(input: { ); } +it.effect("resolves a pull request head file through a provider that supports it", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getFile: (input) => + Effect.succeed({ + url: `https://raw.example/${input.number}/${input.path}?token=one`, + size: 42, + }), + }), + ], + }); + + assert.deepStrictEqual( + yield* service.file({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 7, + path: "docs/screenshot.png", + }), + { + url: "https://raw.example/7/docs/screenshot.png?token=one", + size: 42, + }, + ); + }), +); + +it.effect("refuses pull request files from a provider without file URL support", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [fakeProvider("github")], + }); + + const error = yield* Effect.flip( + service.file({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 7, + path: "docs/screenshot.png", + }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => Effect.gen(function* () { let refinementCalls = 0; @@ -2267,6 +2337,61 @@ it.effect("an explicit invalidation makes the next listing ask the host again", }), ); +it.effect("detail invalidation preserves the cached diff", () => + Effect.gen(function* () { + let detailCalls = 0; + let activityCalls = 0; + let diffCalls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => { + detailCalls += 1; + return Effect.succeed(changeRequestDetail(1)); + }, + getChangeRequestActivity: () => { + activityCalls += 1; + return Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }); + }, + getDiff: () => { + diffCalls += 1; + return Effect.succeed({ patch: "diff", truncated: false, nextCursor: null }); + }, + }), + ], + }); + + yield* Effect.all([ + service.detail(reference), + service.activity(reference), + service.diff(reference), + ]); + yield* service.invalidate({ reference, scope: "detail" }); + yield* Effect.all([ + service.detail(reference), + service.activity(reference), + service.diff(reference), + ]); + + assert.strictEqual(detailCalls, 2); + assert.strictEqual(activityCalls, 2); + assert.strictEqual(diffCalls, 1); + + // Omitting the scope keeps the manual refresh contract: every read is forgotten. + yield* service.invalidate({ reference }); + yield* service.diff(reference); + assert.strictEqual(diffCalls, 2); + }), +); + it.effect("a mutation makes the next listing ask the host again, with no client asking", () => Effect.gen(function* () { let hostCalls = 0; @@ -2651,23 +2776,7 @@ it.effect( fakeProvider("github", { getChangeRequest: () => { coreCalls += 1; - return Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "Ready before the conversation", - changedFiles: 2, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }); + return Effect.succeed(changeRequestDetail(1)); }, getChangeRequestActivity: () => { activityCalls += 1; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index f12f72bdafa..a78e43d1a54 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -24,6 +24,7 @@ import { type PullRequestDiffInput, type PullRequestDiffResult, type PullRequestInvalidateInput, + type PullRequestInvalidationScope, type PullRequestListEntry, type PullRequestListFilters, type PullRequestListInput, @@ -51,6 +52,7 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr import { type ProviderChangeRequest, type ProviderListCursor, + type ProviderPullRequestFile, type PullRequestProviderApi, type PullRequestProviderError, } from "./PullRequestProvider.ts"; @@ -138,6 +140,9 @@ export class PullRequestService extends Context.Service< readonly diffFileContents: ( input: PullRequestDiffFileContentsInput, ) => Effect.Effect; + readonly file: ( + input: PullRequestRef & { readonly path: string }, + ) => Effect.Effect; readonly runAction: (input: PullRequestActionInput) => Effect.Effect; readonly update: (input: PullRequestUpdateInput) => Effect.Effect; readonly comment: (input: PullRequestCommentInput) => Effect.Effect; @@ -1163,6 +1168,27 @@ export const make = Effect.gen(function* () { }), ); + const file: PullRequestService["Service"]["file"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const read = project.api.getFile; + return read + ? read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + path: input.path, + }).pipe(Effect.mapError(toPullRequestError("file"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "file", + detail: "This host cannot provide repository images from a change request.", + }), + ); + }), + ); + const runAction: PullRequestService["Service"]["runAction"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -1732,19 +1758,29 @@ export const make = Effect.gen(function* () { // epoch strands every entry made under the old one — no enumerating a cache whose keys // (cursors, commits) nothing holds a list of. The counter is shared and monotonic so a // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has. + // Detail and activity move together; diff is separate so the live detail view can become + // current without throwing away a large patch it did not ask to reload. let epochCounter = 0; let listingsEpoch = 0; - const refEpochs = new Map(); + type RefCacheScope = "detail" | "diff"; + type RefEpochs = Readonly>; + const refEpochs = new Map(); const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; - const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0; - const bumpRefEpoch = (ref: PullRequestRef) => { - const scope = refScope(ref); - if (!refEpochs.has(scope) && refEpochs.size >= REF_EPOCH_CAPACITY) { + const refEpoch = (scope: RefCacheScope, ref: PullRequestRef) => + refEpochs.get(refScope(ref))?.[scope] ?? 0; + const bumpRefEpoch = (ref: PullRequestRef, scope: PullRequestInvalidationScope = "all") => { + const key = refScope(ref); + if (!refEpochs.has(key) && refEpochs.size >= REF_EPOCH_CAPACITY) { const oldest = refEpochs.keys().next().value; if (oldest !== undefined) refEpochs.delete(oldest); } - refEpochs.set(scope, ++epochCounter); + const epoch = ++epochCounter; + const previous = refEpochs.get(key) ?? { detail: 0, diff: 0 }; + refEpochs.set( + key, + scope === "detail" ? { ...previous, detail: epoch } : { detail: epoch, diff: epoch }, + ); }; /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ @@ -1860,7 +1896,12 @@ export const make = Effect.gen(function* () { DETAIL_CACHE_CAPACITY, ); const detail: PullRequestService["Service"]["detail"] = (input) => { - const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); + const key = JSON.stringify([ + refEpoch("detail", input), + input.projectId, + input.repository, + input.number, + ]); return staleDetail(key, Cache.get(detailCache, key)); }; @@ -1879,7 +1920,12 @@ export const make = Effect.gen(function* () { DETAIL_CACHE_CAPACITY, ); const activity: PullRequestService["Service"]["activity"] = (input) => { - const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); + const key = JSON.stringify([ + refEpoch("detail", input), + input.projectId, + input.repository, + input.number, + ]); return staleActivity(key, Cache.get(activityCache, key)); }; @@ -1916,7 +1962,7 @@ export const make = Effect.gen(function* () { ); const diff: PullRequestService["Service"]["diff"] = (input) => { const key = JSON.stringify([ - refEpoch(input), + refEpoch("diff", input), input.projectId, input.repository, input.number, @@ -1968,7 +2014,7 @@ export const make = Effect.gen(function* () { viewersByHost.clear(); return; } - bumpRefEpoch(input.reference); + bumpRefEpoch(input.reference, input.scope); }); // A mutation's own client re-reads right after it, and every other client's next read must @@ -1995,6 +2041,7 @@ export const make = Effect.gen(function* () { activity, diff, diffFileContents, + file, runAction: invalidatedByMutation(runAction), update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 946394dcda8..e5b402315f1 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -1014,6 +1014,19 @@ describe("REVIEW_THREADS_GRAPHQL_QUERY", () => { // The reviews connection is new: only reactions were ever wanted off it. expect(REVIEW_THREADS_GRAPHQL_QUERY).toContain("reviews(first:"); }); + + it("bounds the nested review-comment reaction fan-out", () => { + const nestedPageSizes = REVIEW_THREADS_GRAPHQL_QUERY.match( + /reviewThreads\(first: (\d+),[\s\S]*?comments\(first: (\d+)\)[\s\S]*?reactionGroups/, + ); + expect(nestedPageSizes).not.toBeNull(); + const threadPageSize = Number(nestedPageSizes?.[1]); + const commentPageSize = Number(nestedPageSizes?.[2]); + + // GitHub charges nested connections from their declared worst-case parent fan-out. Keeping + // this product bounded prevents one activity refresh from consuming ~100 GraphQL points. + expect(threadPageSize * commentPageSize).toBeLessThanOrEqual(250); + }); }); describe("reviewer candidate decoding", () => { diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 7b9ff9d41fe..f0d36bbaef8 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -605,6 +605,16 @@ export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commit /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ const GRAPHQL_PAGE_SIZE = 100; +/** + * Keep the two nested review-thread connections narrow. GitHub prices a GraphQL query from its + * declared worst-case shape: putting a connection below 100 threads and 100 comments makes that + * inner connection cost ten thousand potential reads even when the pull request is small. + * Continuation reads below still walk every page, so these bounds trade round trips on unusually + * large conversations for a predictable cost on every ordinary one. + */ +const REVIEW_THREAD_PAGE_SIZE = 25; +const REVIEW_THREAD_INITIAL_COMMENT_PAGE_SIZE = 10; + /** * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: * `first: 101` is an error, `first: 100` is not). @@ -679,7 +689,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin viewer { login } repository(owner: $owner, name: $name) { pullRequest(number: $number) { - reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { + reviewThreads(first: ${REVIEW_THREAD_PAGE_SIZE}, after: $cursor) { totalCount pageInfo { hasNextPage endCursor } nodes { @@ -689,7 +699,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin path line diffSide - comments(first: ${GRAPHQL_PAGE_SIZE}) { + comments(first: ${REVIEW_THREAD_INITIAL_COMMENT_PAGE_SIZE}) { totalCount pageInfo { hasNextPage endCursor } nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 89f903c4f89..b5d14a6fc46 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -111,6 +111,7 @@ import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ComposerDrafts from "./persistence/ComposerDrafts.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; @@ -122,6 +123,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; +import * as TerminalBrowserOpen from "./preview/TerminalBrowserOpen.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; @@ -401,6 +403,7 @@ const buildAppUnderTest = (options?: { ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; terminalManager?: Partial; + terminalBrowserOpen?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; checkpointDiffQuery?: Partial; @@ -617,14 +620,17 @@ const buildAppUnderTest = (options?: { }, ).pipe( Layer.provide( - Layer.mock(Keybindings.Keybindings)({ - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], + Layer.mergeAll( + ComposerDrafts.layer.pipe(Layer.provide(SqlitePersistenceMemory)), + Layer.mock(Keybindings.Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + ...options?.layers?.keybindings, }), - streamChanges: Stream.empty, - ...options?.layers?.keybindings, - }), + ), ), Layer.provide( Layer.mock(ProviderRegistry.ProviderRegistry)({ @@ -741,9 +747,18 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(TerminalManager.TerminalManager)({ - ...options?.layers?.terminalManager, - }), + Layer.merge( + Layer.mock(TerminalManager.TerminalManager)({ + ...options?.layers?.terminalManager, + }), + Layer.mock(TerminalBrowserOpen.TerminalBrowserOpen)({ + register: () => Effect.succeed({}), + unregister: () => Effect.void, + resolve: () => Effect.succeed(undefined), + openInPreview: () => Effect.void, + ...options?.layers?.terminalBrowserOpen, + }), + ), ), Layer.provide( Layer.mergeAll( @@ -4483,6 +4498,105 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("shares composer draft updates across websocket sessions", () => + Effect.scoped( + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const threadId = ThreadId.make("shared-composer-draft"); + const subscribed = yield* Deferred.make(); + const snapshotsFiber = yield* withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeComposerDraft]({ threadId }).pipe( + Stream.tap(() => Deferred.succeed(subscribed, undefined)), + Stream.take(2), + Stream.runCollect, + ), + ).pipe(Effect.forkScoped); + + yield* Deferred.await(subscribed); + const update = yield* withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.composerDraftUpdate]({ + threadId, + baseRevision: 0, + common: { + text: "shared from another websocket", + modelSelection: null, + runtimeMode: null, + interactionMode: null, + }, + clientMutationId: "test:shared-composer-draft", + }), + ); + const snapshots = Array.from(yield* Fiber.join(snapshotsFiber)); + + assert.equal(update._tag, "accepted"); + assert.equal(snapshots[0]?.revision, 0); + assert.deepEqual(snapshots[1], update.snapshot); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("clears the matching composer draft revision after thread.turn.start", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const threadId = ThreadId.make("sent-composer-draft"); + const commandId = CommandId.make("cmd-send-composer-draft"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const update = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.composerDraftUpdate]({ + threadId, + baseRevision: 0, + common: { + text: "send this draft", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + }, + clientMutationId: "test:send-composer-draft", + }), + ), + ); + assert.equal(update._tag, "accepted"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId, + threadId, + message: { + messageId: MessageId.make("msg-send-composer-draft"), + role: "user", + text: "send this draft", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + composerDraftRevision: update.snapshot.revision, + createdAt, + }), + ), + ); + + const cleared = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeComposerDraft]({ threadId }).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + ), + ); + assert.equal(cleared.revision, 2); + assert.isNull(cleared.common); + assert.equal(cleared.clientMutationId, `turn:${commandId}`); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects websocket rpc handshake when session authentication is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -7364,6 +7478,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ) => Effect.succeed({ status: "started" as const, + runId: "run-setup", scriptId: "setup", scriptName: "Setup", terminalId: "setup-setup", @@ -7437,16 +7552,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 5); + assert.equal(response.sequence, 3); assert.deepEqual( dispatchedCommands.map((command) => command.type), - [ - "thread.create", - "thread.meta.update", - "thread.activity.append", - "thread.activity.append", - "thread.turn.start", - ], + ["thread.create", "thread.meta.update", "thread.turn.start"], ); assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", @@ -7478,15 +7587,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); - const setupActivities = dispatchedCommands.filter( - (command): command is Extract => - command.type === "thread.activity.append", - ); - assert.deepEqual( - setupActivities.map((command) => command.activity.kind), - ["setup-script.requested", "setup-script.started"], - ); - const finalCommand = dispatchedCommands[4]; + const finalCommand = dispatchedCommands[2]; assertTrue(finalCommand?.type === "thread.turn.start"); if (finalCommand?.type === "thread.turn.start") { assert.equal(finalCommand.bootstrap, undefined); @@ -7598,7 +7699,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("records setup-script failures without aborting bootstrap turn start", () => + it.effect("records setup-script resolution failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( @@ -7620,8 +7721,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ threadId: input.threadId, worktreePath: input.worktreePath, - operation: "openTerminal", - cause: { message: "pty unavailable" }, + operation: "resolveProject", + cause: { message: "project unavailable" }, }), ), ); @@ -7696,14 +7797,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); assert.deepEqual(setupFailureActivity?.activity.payload, { - detail: "pty unavailable", + detail: "project unavailable", worktreePath: "/tmp/bootstrap-worktree", }); assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => + it.effect("does not duplicate runner-owned setup activities in bootstrap", () => Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( @@ -7723,41 +7824,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ) => Effect.succeed({ status: "started" as const, + runId: "run-setup", scriptId: "setup", scriptName: "Setup", terminalId: "setup-setup", cwd: "/tmp/bootstrap-worktree", }), ); - let setupActivityAppendAttempt = 0; - yield* buildAppUnderTest({ layers: { gitVcsDriver: { createWorktree, }, orchestrationEngine: { - dispatch: (command) => { - if ( - command.type === "thread.activity.append" && - command.activity.kind.startsWith("setup-script.") - ) { - setupActivityAppendAttempt += 1; - if (setupActivityAppendAttempt === 2) { - return Effect.fail( - new OrchestrationListenerCallbackError({ - listener: "domain-event", - detail: "failed to append setup-script.started activity", - }), - ); - } - } - - return Effect.sync(() => { + dispatch: (command) => + Effect.sync(() => { dispatchedCommands.push(command); return { sequence: dispatchedCommands.length }; - }); - }, + }), readEvents: () => Stream.empty, }, projectSetupScriptRunner: { @@ -7806,19 +7890,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(response.sequence, 4); + assert.equal(response.sequence, 3); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + ["thread.create", "thread.meta.update", "thread.turn.start"], ); const setupActivities = dispatchedCommands.filter( (command): command is Extract => command.type === "thread.activity.append", ); - assert.deepEqual( - setupActivities.map((command) => command.activity.kind), - ["setup-script.requested"], - ); + assert.deepEqual(setupActivities, []); assertTrue( setupActivities.every((command) => command.activity.kind !== "setup-script.failed"), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2226449eec0..2429072c857 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -17,6 +17,7 @@ import { staticAndDevRouteLayer, browserApiCorsLayer, httpCompressionLayer, + terminalBrowserOpenRouteLayer, } from "./http.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; @@ -25,6 +26,7 @@ import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import * as ComposerDrafts from "./persistence/ComposerDrafts.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; @@ -48,6 +50,7 @@ import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; +import * as TerminalBrowserOpen from "./preview/TerminalBrowserOpen.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -68,6 +71,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; +import * as WorkspacePortAllocator from "./workspace/WorkspacePortAllocator.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; @@ -107,6 +111,7 @@ import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinar import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import * as CommandOutputQuery from "./orchestration/CommandOutputQuery.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -257,13 +262,21 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( // `create()`; `ProviderEventLoggers.layer` owns the shared native/canonical // NDJSON writers and is provided at the outer runtime layer so both // `ProviderService` and the per-instance drivers read the same logger pair. +const PersistenceLayerLive = Layer.mergeAll( + SqlitePersistenceLayerLive, + CommandOutputQuery.layer.pipe(Layer.provide(SqlitePersistenceLayerLive)), +); + +const WorkspacePortAllocatorLayerLive = WorkspacePortAllocator.layer.pipe( + Layer.provide(SqlitePersistenceLayerLive), +); + const ProviderLayerLive = ProviderServiceLive.pipe( Layer.provide(ProviderAdapterRegistryLive), Layer.provideMerge(ProviderSessionDirectoryLayerLive), + Layer.provide(WorkspacePortAllocatorLayerLive), ); -const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); - const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -320,9 +333,20 @@ const CheckpointingLayerLive = Layer.empty.pipe( const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); -const TerminalLayerLive = TerminalManager.layer.pipe( - Layer.provide(PtyAdapterLive), - Layer.provide(PortScannerLayerLive), +const PreviewAutomationBrokerLayerLive = PreviewAutomationBroker.layer; + +const TerminalBrowserOpenLayerLive = TerminalBrowserOpen.layer.pipe( + Layer.provide(PreviewAutomationBrokerLayerLive), +); + +const TerminalLayerLive = Layer.merge( + TerminalBrowserOpenLayerLive, + TerminalManager.layer.pipe( + Layer.provide(PtyAdapterLive), + Layer.provide(PortScannerLayerLive), + Layer.provide(WorkspacePortAllocatorLayerLive), + Layer.provide(TerminalBrowserOpenLayerLive), + ), ); const PreviewLayerLive = Layer.empty.pipe( @@ -374,7 +398,16 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), + Layer.provideMerge( + Layer.mergeAll( + TerminalLayerLive, + PreviewLayerLive, + ComposerDrafts.layer, + // WebSocket, MCP, and terminal browser-open callbacks must all route through + // one broker so focused-host selection and tab assignments stay coherent. + PreviewAutomationBrokerLayerLive, + ), + ), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(Keybindings.layer), Layer.provideMerge(ProviderRegistryLive), @@ -452,16 +485,15 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(environmentAuthenticatedAuthLayer), ), otlpTracesProxyRouteLayer, + terminalBrowserOpenRouteLayer, assetRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe( - // Both transports consume the same service instance, so caches single-flight across clients - // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), - Layer.provide(PreviewAutomationBroker.layer), + Layer.provide(PreviewAutomationBrokerLayerLive), Layer.provide(ServerSelfUpdate.layer), Layer.provide(commandReadinessLayer), Layer.provide(browserApiCorsLayer), diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac982927..c94f69ea2af 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -77,6 +77,7 @@ export const make = Effect.gen(function* () { return SourceControlProvider.SourceControlProvider.of({ kind: "azure-devops", + ...SourceControlProvider.unsupportedIssueOperations("azure-devops"), listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); return azure diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 974fbb94a39..c5e1d633624 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -35,6 +35,7 @@ export const make = Effect.gen(function* () { return SourceControlProvider.SourceControlProvider.of({ kind: "bitbucket", + ...SourceControlProvider.unsupportedIssueOperations("bitbucket"), listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); return bitbucket diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 964ed3d021c..cb578e2d8c6 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -263,6 +263,136 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("lists open issues and drops invalid rows", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 0, + title: "invalid", + url: "https://github.com/pingdotgg/codething-mvp/issues/0", + }, + { + number: 123, + title: " Fix login crash ", + url: " https://github.com/pingdotgg/codething-mvp/issues/123 ", + state: "OPEN", + labels: [{ name: " bug " }, { name: " " }], + updatedAt: "2026-01-02T03:04:05Z", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listIssues({ cwd: "/repo" }); + + assert.equal(result.length, 1); + assert.deepStrictEqual( + result.map(({ updatedAt: _updatedAt, ...issue }) => issue), + [ + { + number: 123, + title: "Fix login crash", + url: "https://github.com/pingdotgg/codething-mvp/issues/123", + state: "open", + labels: ["bug"], + }, + ], + ); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "issue", + "list", + "--state", + "open", + "--limit", + "50", + "--json", + "number,title,state,url,labels,updatedAt", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("returns an empty issue list when gh prints nothing", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listIssues({ cwd: "/repo" }); + + assert.deepStrictEqual(result, []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads a single issue with its comments", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 123, + title: "Fix login crash", + url: "https://github.com/pingdotgg/codething-mvp/issues/123", + state: "OPEN", + body: "\nSteps to reproduce\n", + author: { login: "octocat" }, + comments: [ + { author: { login: "hubot" }, body: " Repros here " }, + { author: null, body: null }, + ], + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getIssue({ cwd: "/repo", reference: "123" }); + + assert.deepStrictEqual(result, { + number: 123, + title: "Fix login crash", + url: "https://github.com/pingdotgg/codething-mvp/issues/123", + state: "open", + repository: "pingdotgg/codething-mvp", + author: "octocat", + body: "Steps to reproduce", + comments: [ + { author: "hubot", body: "Repros here" }, + { author: null, body: "" }, + ], + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["issue", "view", "123", "--json", "number,title,state,url,body,author,comments"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("fails with a decode error when issue json is malformed", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{ not json"))); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh.getIssue({ cwd: "/repo", reference: "123" }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitHubIssueDecodeError"); + }).pipe(Effect.provide(layer)), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -292,6 +422,50 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("reports the fork parent when the repository has one", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + // Shape `gh repo view --json parent` really returns, ids included. + parent: { + id: "R_kgDORLtfbQ", + name: "codething-mvp", + owner: { id: "MDEyOk9yZ2FuaXphdGlvbg==", login: "codething" }, + }, + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/codething-mvp", + }); + + assert.strictEqual(result.parentNameWithOwner, "codething/codething-mvp"); + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "repo", + "view", + "octocat/codething-mvp", + "--json", + "nameWithOwner,url,sshUrl,parent", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("creates repositories and parses clone URLs from create output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -403,4 +577,86 @@ describe("GitHubCli.layer", () => { assert.notInclude(error.message, "user ID"); }).pipe(Effect.provide(layer)), ); + + /** + * `gh api graphql` exits non-zero whenever any part of an answer failed, while still printing + * the parts that resolved. Reading the body rather than the exit code is what lets one missing + * reference travel beside a dozen good ones — and telling that apart from a body that never + * arrived is what keeps a logged-out host from reading as a page full of dead references. + */ + it.effect("keeps a partial answer that the host exited non-zero over", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(1), + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdout: JSON.stringify({ + data: { r0: { i0: { __typename: "Issue", title: "A bug", url: "u", state: "OPEN" } } }, + errors: [{ type: "NOT_FOUND", path: ["r0", "i1"] }], + }), + stderr: "gh: Could not resolve to an issue or pull request with the number of 2.", + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + const github = yield* GitHubCli.GitHubCli; + + const resolved = yield* github.resolveReferences({ + cwd: "/repo", + host: "github.com", + references: [ + { repository: "owner/repo", number: 1 }, + { repository: "owner/repo", number: 2 }, + ], + }); + + assert.deepStrictEqual( + resolved.map((reference) => [reference.number, reference.kind]), + [ + [1, "issue"], + [2, null], + ], + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("says why an answer never arrived rather than reporting no references", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(4), + stdout: "", + stderr: "gh: To get started with GitHub CLI, please run: gh auth login", + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + const github = yield* GitHubCli.GitHubCli; + + const failure = yield* github + .resolveReferences({ + cwd: "/repo", + host: "github.com", + references: [{ repository: "owner/repo", number: 1 }], + }) + .pipe(Effect.flip); + + assert.equal(failure._tag, "GitHubCliAuthenticationError"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("asks nothing at all when no reference names a repository", () => + Effect.gen(function* () { + const github = yield* GitHubCli.GitHubCli; + + const resolved = yield* github.resolveReferences({ + cwd: "/repo", + host: "github.com", + references: [{ repository: "notarepository", number: 1 }], + }); + + assert.deepStrictEqual(resolved, []); + assert.equal(mockRun.mock.calls.length, 0); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd20..2051afb7af1 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -11,13 +11,27 @@ import { type VcsError, } from "@t3tools/contracts"; +import { encodeGraphQlRequestJson } from "../pullRequest/gitHubPullRequestJson.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { + buildGitHubReferenceQuery, + decodeGitHubReferenceResponseJson, + type GitHubReferenceRequest, + type GitHubResolvedReference, +} from "./gitHubReferences.ts"; +import { + decodeGitHubIssueJson, + decodeGitHubIssueListJson, + type NormalizedGitHubIssue, + type NormalizedGitHubIssueSummary, +} from "./gitHubIssues.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_ISSUE_LIST_LIMIT = 50; const gitHubCliFailureFields = { command: Schema.Literal("gh"), @@ -135,6 +149,45 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedErrorClass()( + "GitHubIssueListDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid issue list JSON."; + } + + override get message(): string { + return `GitHub CLI failed in listIssues: ${this.detail}`; + } +} + +export class GitHubIssueDecodeError extends Schema.TaggedErrorClass()( + "GitHubIssueDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid issue JSON."; + } + + override get message(): string { + return `GitHub CLI failed in getIssue: ${this.detail}`; + } +} + +export class GitHubReferenceDecodeError extends Schema.TaggedErrorClass()( + "GitHubReferenceDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid reference JSON."; + } + + override get message(): string { + return `GitHub CLI failed in resolveReferences: ${this.detail}`; + } +} + export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( "GitHubRepositoryDecodeError", gitHubCliDecodeFields, @@ -157,6 +210,9 @@ export const GitHubCliError = Schema.Union([ GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, + GitHubIssueListDecodeError, + GitHubIssueDecodeError, + GitHubReferenceDecodeError, GitHubRepositoryDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -207,10 +263,14 @@ export interface GitHubPullRequestSummary { readonly headRepositoryOwnerLogin?: string | null; } +export type GitHubIssueSummary = NormalizedGitHubIssueSummary; +export type GitHubIssue = NormalizedGitHubIssue; + export interface GitHubRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; readonly sshUrl: string; + readonly parentNameWithOwner?: string; } export class GitHubCli extends Context.Service< @@ -223,6 +283,8 @@ export class GitHubCli extends Context.Service< /** Piped to the child's stdin, for payloads that must never appear in argv. */ readonly stdin?: string; readonly maxOutputBytes?: number; + /** Keeps the output of a command whose non-zero exit the caller reads for itself. */ + readonly allowNonZeroExit?: boolean; }) => Effect.Effect; readonly listOpenPullRequests: (input: { @@ -236,6 +298,23 @@ export class GitHubCli extends Context.Service< readonly reference: string; }) => Effect.Effect; + readonly listIssues: (input: { + readonly cwd: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + + readonly getIssue: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + /** What each `owner/repo#number` turns out to be, asked at once. */ + readonly resolveReferences: (input: { + readonly cwd: string; + readonly host: string; + readonly references: ReadonlyArray; + }) => Effect.Effect, GitHubCliError>; + readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; @@ -267,10 +346,17 @@ export class GitHubCli extends Context.Service< } >()("t3/sourceControl/GitHubCli") {} +/** `gh repo view --json parent` reports the fork parent as owner/name, without its URLs. */ +const RawGitHubRepositoryParentSchema = Schema.Struct({ + name: TrimmedNonEmptyString, + owner: Schema.Struct({ login: TrimmedNonEmptyString }), +}); + const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, + parent: Schema.optional(Schema.NullOr(RawGitHubRepositoryParentSchema)), }); const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), @@ -283,6 +369,7 @@ function normalizeRepositoryCloneUrls( nameWithOwner: raw.nameWithOwner, url: raw.url, sshUrl: raw.sshUrl, + ...(raw.parent ? { parentNameWithOwner: `${raw.parent.owner.login}/${raw.parent.name}` } : {}), }; } @@ -336,6 +423,7 @@ export const make = Effect.gen(function* () { timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), + ...(input.allowNonZeroExit === true ? { allowNonZeroExit: true } : {}), }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); @@ -412,10 +500,109 @@ export const make = Effect.gen(function* () { ), ), ), + listIssues: (input) => + execute({ + cwd: input.cwd, + args: [ + "issue", + "list", + "--state", + "open", + "--limit", + String(input.limit ?? DEFAULT_ISSUE_LIST_LIMIT), + "--json", + "number,title,state,url,labels,updatedAt", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => decodeGitHubIssueListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubIssueListDecodeError({ + command: "gh", + cwd: input.cwd, + cause: decoded.failure, + }), + ), + ), + ), + ), + ), + getIssue: (input) => + execute({ + cwd: input.cwd, + args: [ + "issue", + "view", + input.reference, + "--json", + "number,title,state,url,body,author,comments", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => decodeGitHubIssueJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubIssueDecodeError({ + command: "gh", + cwd: input.cwd, + cause: decoded.failure, + }), + ), + ), + ), + ), + ), + resolveReferences: (input) => { + const built = buildGitHubReferenceQuery(input.references); + if (built === null) return Effect.succeed([]); + return execute({ + cwd: input.cwd, + args: ["api", "graphql", "--hostname", input.host, "--input", "-"], + // Over stdin: a variable carries a repository path a body wrote, and argv is visible in + // process listings and echoed back in process-runner failures. + stdin: encodeGraphQlRequestJson({ query: built.query, variables: built.variables }), + // `gh` exits non-zero when any part failed — a reference nobody can see is exactly that — + // while still printing what did resolve. The exit code is read below, against the body. + allowNonZeroExit: true, + }).pipe( + Effect.flatMap( + (result): Effect.Effect, GitHubCliError> => { + const decoded = decodeGitHubReferenceResponseJson(result.stdout.trim(), built.aliases); + if (Result.isSuccess(decoded)) return Effect.succeed(decoded.success); + const context = { command: "gh", cwd: input.cwd } as const; + if (result.exitCode === 0) { + return Effect.fail( + new GitHubReferenceDecodeError({ ...context, cause: decoded.failure }), + ); + } + // No answer at all: saying why keeps a rate-limited or logged-out host from reading + // as a body full of references that do not exist. + const cause = result.stderr; + switch (VcsProcess.classifyNonZeroExit("gh", result.stderr)) { + case "authentication": + return Effect.fail(new GitHubCliAuthenticationError({ ...context, cause })); + case "rate-limited": + return Effect.fail(new GitHubCliRateLimitError({ ...context, cause })); + default: + return Effect.fail(new GitHubCliCommandError({ ...context, cause })); + } + }, + ), + ); + }, getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, - args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl,parent"], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..a73291e2b65 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -6,6 +6,8 @@ import { SourceControlProviderError, type ChangeRequest, type ChangeRequestState, + type SourceControlIssue, + type SourceControlIssueSummary, } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -42,6 +44,32 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } +function toIssueSummary(issue: GitHubCli.GitHubIssueSummary): SourceControlIssueSummary { + return { + provider: "github", + number: issue.number, + title: issue.title, + url: issue.url, + state: issue.state, + labels: issue.labels, + updatedAt: issue.updatedAt, + }; +} + +function toIssue(issue: GitHubCli.GitHubIssue): SourceControlIssue { + return { + provider: "github", + repository: issue.repository, + number: issue.number, + title: issue.title, + url: issue.url, + state: issue.state, + author: issue.author, + body: issue.body, + comments: issue.comments, + }; +} + function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -205,6 +233,62 @@ export const make = Effect.gen(function* () { }), ), ), + listIssues: (input) => + github + .listIssues({ + cwd: input.cwd, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((issues) => issues.map(toIssueSummary)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listIssues", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), + getIssue: (input) => + github.getIssue({ cwd: input.cwd, reference: String(input.number) }).pipe( + Effect.map(toIssue), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getIssue", + command: error.command, + cwd: input.cwd, + reference: String(input.number), + detail: error.detail, + cause: error, + }), + ), + ), + resolveReferences: (input) => + github + .resolveReferences({ + cwd: input.cwd, + host: input.host, + references: input.references, + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "resolveReferences", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), createChangeRequest: (input) => github .createPullRequest({ diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2cba12f1b3f..442d81812f4 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -105,6 +105,7 @@ export const make = Effect.gen(function* () { return SourceControlProvider.SourceControlProvider.of({ kind: "gitlab", + ...SourceControlProvider.unsupportedIssueOperations("gitlab"), listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); return gitlab diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa42..c9ac05b7a6f 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -1,11 +1,15 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import { SourceControlProviderError } from "@t3tools/contracts"; import type { ChangeRequest, ChangeRequestState, - SourceControlProviderError, SourceControlProviderInfo, SourceControlProviderKind, + SourceControlIssue, + SourceControlIssueSummary, + SourceControlReference, + SourceControlResolvedReference, SourceControlRepositoryCloneUrls, SourceControlRepositoryVisibility, } from "@t3tools/contracts"; @@ -96,6 +100,22 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly reference: string; }) => Effect.Effect; + readonly listIssues: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly limit?: number; + }) => Effect.Effect, SourceControlProviderError>; + readonly getIssue: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly number: number; + }) => Effect.Effect; + readonly resolveReferences: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly host: string; + readonly references: ReadonlyArray; + }) => Effect.Effect, SourceControlProviderError>; readonly createChangeRequest: (input: { readonly cwd: string; readonly context?: SourceControlProviderContext; @@ -128,3 +148,35 @@ export class SourceControlProvider extends Context.Service< }) => Effect.Effect; } >()("t3/sourceControl/SourceControlProvider") {} + +/** + * Issue browsing only ships for GitHub today. Every other provider reuses this + * so the capability gap is a typed, explainable failure instead of a missing + * method. + * + * Resolving references belongs here too, answering empty rather than failing: no reference + * resolved is exactly right for a host whose bodies do not write GitHub's shorthand. + */ +export function unsupportedIssueOperations( + kind: SourceControlProviderKind, + detail = `Browsing issues is not supported for ${kind} yet.`, +): Pick { + return { + resolveReferences: () => Effect.succeed([]), + listIssues: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "listIssues", + cwd: input.cwd, + detail, + }), + getIssue: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getIssue", + cwd: input.cwd, + reference: String(input.number), + detail, + }), + }; +} diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 9fe089a4184..f30cc5ba884 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -82,6 +82,10 @@ function unsupportedProvider( reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), detail: `No ${kind} source control provider is registered.`, }), + ...SourceControlProvider.unsupportedIssueOperations( + kind, + `No ${kind} source control provider is registered.`, + ), createChangeRequest: (input) => new SourceControlProviderError({ provider: kind, @@ -165,11 +169,26 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + resolveReferences: (input) => + provider.resolveReferences({ + ...input, + context: input.context ?? context, + }), getChangeRequest: (input) => provider.getChangeRequest({ ...input, context: input.context ?? context, }), + listIssues: (input) => + provider.listIssues({ + ...input, + context: input.context ?? context, + }), + getIssue: (input) => + provider.getIssue({ + ...input, + context: input.context ?? context, + }), createChangeRequest: (input) => provider.createChangeRequest({ ...input, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e0..c7b336cf028 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -21,6 +21,22 @@ const CLONE_URLS = { sshUrl: "git@github.com:octocat/t3code.git", }; +const PARENT_URLS = { + nameWithOwner: "t3/t3code", + url: "https://github.com/t3/t3code", + sshUrl: "git@github.com:t3/t3code.git", +}; + +const FORK_URLS = { ...CLONE_URLS, parentNameWithOwner: PARENT_URLS.nameWithOwner }; + +/** Answers the fork lookup and the follow-up parent lookup from one provider mock. */ +function makeForkProvider() { + return makeProvider({ + getRepositoryCloneUrls: (input) => + Effect.succeed(input.repository === PARENT_URLS.nameWithOwner ? PARENT_URLS : FORK_URLS), + }); +} + function makeProvider( overrides: Partial = {}, ): SourceControlProvider.SourceControlProvider["Service"] { @@ -33,6 +49,9 @@ function makeProvider( return { kind: "github", listChangeRequests: () => unsupported("listChangeRequests"), + listIssues: () => unsupported("listIssues"), + getIssue: () => unsupported("getIssue"), + resolveReferences: () => unsupported("resolveReferences"), getChangeRequest: () => unsupported("getChangeRequest"), createChangeRequest: () => unsupported("createChangeRequest"), getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), @@ -68,6 +87,8 @@ function makeLayer(input: { Layer.mock(GitVcsDriver.GitVcsDriver)({ execute: () => Effect.succeed(processOutput()), ensureRemote: () => Effect.succeed("origin"), + resolvePrimaryRemoteName: () => Effect.succeed("origin"), + fetchRemote: () => Effect.void, pushCurrentBranch: () => Effect.succeed({ status: "pushed" as const, @@ -179,6 +200,7 @@ it.effect("clones a looked-up repository into the requested destination", () => args: ["clone", CLONE_URLS.url, "t3code"], }, ]); + assert.strictEqual("upstream" in result, false); }).pipe( Effect.provide( makeLayer({ @@ -195,6 +217,422 @@ it.effect("clones a looked-up repository into the requested destination", () => }).pipe(Effect.provide(NodeServices.layer)), ); +/** Answers `git config --get-regexp` for a fork clone that already has both remotes. */ +function forkRemoteConfigStdout(defaultRemoteName: string | null): string { + return [ + `remote.origin.url ${CLONE_URLS.url}`, + `remote.upstream.url ${PARENT_URLS.url}`, + ...(defaultRemoteName ? [`remote.${defaultRemoteName}.gh-resolved base`] : []), + ].join("\n"); +} + +it.effect("lists remote candidates and the current default repository", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const state = yield* service.getDefaultRepository({ cwd: "/workspace" }); + + assert.deepStrictEqual(state, { + remotes: [ + { + remoteName: "origin", + url: CLONE_URLS.url, + nameWithOwner: "octocat/t3code", + provider: "github", + }, + { + remoteName: "upstream", + url: PARENT_URLS.url, + nameWithOwner: "t3/t3code", + provider: "github", + }, + ], + defaultRemoteName: "upstream", + }); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: () => + Effect.succeed({ ...processOutput(), stdout: forkRemoteConfigStdout("upstream") }), + }, + }), + ), + ), +); + +it.effect("moves the default repository pin to the chosen remote", () => { + const configCalls: Array> = []; + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + yield* service.setDefaultRepository({ cwd: "/workspace", remoteName: "origin" }); + + assert.deepStrictEqual(configCalls, [ + ["config", "--unset-all", "remote.upstream.gh-resolved"], + ["config", "--replace-all", "remote.origin.gh-resolved", "base"], + ]); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => + Effect.sync(() => { + if (input.args[1] !== "--get-regexp") { + configCalls.push(input.args); + } + return { ...processOutput(), stdout: forkRemoteConfigStdout("upstream") }; + }), + }, + }), + ), + ); +}); + +it.effect("rejects a default repository that is not one of the remotes", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.setDefaultRepository({ cwd: "/workspace", remoteName: "fork" }), + ); + + assert.strictEqual(error.operation, "setDefaultRepository"); + assert.strictEqual(error.detail, "Choose a remote that exists in this repository."); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: () => + Effect.succeed({ ...processOutput(), stdout: forkRemoteConfigStdout(null) }), + }, + }), + ), + ), +); + +it.effect("clears every pin when the default repository is unset", () => { + const configCalls: Array> = []; + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const state = yield* service.setDefaultRepository({ cwd: "/workspace", remoteName: null }); + + assert.deepStrictEqual(configCalls, [["config", "--unset-all", "remote.upstream.gh-resolved"]]); + // The mocked config keeps reporting the pin, so this asserts the read-back + // shape rather than the cleared value. + assert.strictEqual(state.remotes.length, 2); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => + Effect.sync(() => { + if (input.args[1] !== "--get-regexp") { + configCalls.push(input.args); + } + return { ...processOutput(), stdout: forkRemoteConfigStdout("upstream") }; + }), + }, + }), + ), + ); +}); + +it.effect("reports the repository a pin names when it is not the remote's own", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const state = yield* service.getDefaultRepository({ cwd: "/workspace" }); + + // What `gh repo set-default` writes for a fork cloned without an upstream + // remote: the pin lives on origin but names the parent repository. + assert.strictEqual(state.defaultRemoteName, "origin"); + assert.strictEqual(state.defaultRepositoryPath, PARENT_URLS.nameWithOwner); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: () => + Effect.succeed({ + ...processOutput(), + stdout: [ + `remote.origin.url ${CLONE_URLS.url}`, + `remote.origin.gh-resolved ${PARENT_URLS.nameWithOwner}`, + ].join("\n"), + }), + }, + }), + ), + ), +); + +it.effect("reads remote names containing dots, and a repository with no remotes", () => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const state = yield* service.getDefaultRepository({ cwd: "/workspace" }); + + assert.deepStrictEqual( + state.remotes.map((remote) => remote.remoteName), + ["my.fork"], + ); + assert.strictEqual(state.defaultRemoteName, "my.fork"); + + const empty = yield* service.getDefaultRepository({ cwd: "/empty" }); + assert.deepStrictEqual(empty, { remotes: [], defaultRemoteName: null }); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => + Effect.succeed({ + ...processOutput(), + stdout: + input.cwd === "/empty" + ? "" + : [ + `remote.my.fork.url ${CLONE_URLS.url}`, + "remote.my.fork.gh-resolved base", + ].join("\n"), + }), + }, + }), + ), + ), +); + +it.effect("wires a cloned fork to its parent and pins the fork by default", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-fork-", + }); + const destinationPath = `${parent}/t3code`; + const gitCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; + const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.cloneRepository({ + provider: "github", + repository: "octocat/t3code", + destinationPath, + protocol: "https", + }); + + assert.deepStrictEqual(result.upstream, { + remoteName: "upstream", + nameWithOwner: PARENT_URLS.nameWithOwner, + remoteUrl: PARENT_URLS.url, + }); + assert.deepStrictEqual(remoteCalls, [ + { cwd: destinationPath, preferredName: "upstream", url: PARENT_URLS.url }, + ]); + assert.deepStrictEqual(gitCalls, [ + { cwd: parent, args: ["clone", CLONE_URLS.url, "t3code"] }, + { + cwd: destinationPath, + args: ["config", "--replace-all", "remote.origin.gh-resolved", "base"], + }, + ]); + }).pipe( + Effect.provide( + makeLayer({ + provider: makeForkProvider(), + git: { + execute: (input) => + Effect.sync(() => { + gitCalls.push({ cwd: input.cwd, args: input.args }); + return processOutput(); + }), + ensureRemote: (input) => + Effect.sync(() => { + remoteCalls.push(input); + return "upstream"; + }), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("pins the parent when the clone asks to contribute upstream", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-fork-parent-", + }); + const destinationPath = `${parent}/t3code`; + const gitCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; + const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + yield* service.cloneRepository({ + provider: "github", + repository: "octocat/t3code", + destinationPath, + protocol: "https", + defaultRepository: "parent", + }); + + assert.deepStrictEqual(gitCalls, [ + { cwd: parent, args: ["clone", CLONE_URLS.url, "t3code"] }, + { + cwd: destinationPath, + args: ["config", "--replace-all", "remote.upstream.gh-resolved", "base"], + }, + ]); + }).pipe( + Effect.provide( + makeLayer({ + provider: makeForkProvider(), + git: { + execute: (input) => + Effect.sync(() => { + gitCalls.push({ cwd: input.cwd, args: input.args }); + return processOutput(); + }), + ensureRemote: (input) => + Effect.sync(() => { + remoteCalls.push(input); + return "upstream"; + }), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("fetches the upstream remote, and keeps the clone when that fetch fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-fork-fetch-", + }); + const destinationPath = `${parent}/t3code`; + const fetchCalls: Array<{ cwd: string; remoteName: string }> = []; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.cloneRepository({ + provider: "github", + repository: "octocat/t3code", + destinationPath, + }); + + assert.deepStrictEqual(fetchCalls, [{ cwd: destinationPath, remoteName: "upstream" }]); + // The remote is wired up either way, so a failed fetch still reports it. + assert.strictEqual(result.upstream?.remoteName, "upstream"); + }).pipe( + Effect.provide( + makeLayer({ + provider: makeForkProvider(), + git: { + ensureRemote: () => Effect.succeed("upstream"), + fetchRemote: (input) => + Effect.sync(() => { + fetchCalls.push(input); + }).pipe( + Effect.andThen( + new GitCommandError({ + operation: "GitVcsDriver.fetchRemote", + command: "git fetch upstream", + cwd: input.cwd, + detail: "fatal: could not read from remote repository", + }), + ), + ), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("keeps a fork clone when the upstream remote cannot be added", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-fork-failure-", + }); + const destinationPath = `${parent}/t3code`; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.cloneRepository({ + provider: "github", + repository: "octocat/t3code", + destinationPath, + }); + + assert.strictEqual(result.cwd, destinationPath); + assert.strictEqual(result.upstream, undefined); + }).pipe( + Effect.provide( + makeLayer({ + provider: makeForkProvider(), + git: { + ensureRemote: (input) => + new GitCommandError({ + operation: "GitVcsDriver.ensureRemote.add", + command: "git remote add upstream", + cwd: input.cwd, + detail: "fatal: could not add remote", + }), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("falls back to the requested clone URL when the repository lookup fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-lookup-failure-", + }); + const destinationPath = `${parent}/t3code`; + const cloneCalls: Array> = []; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.cloneRepository({ + provider: "github", + repository: "octocat/t3code", + remoteUrl: CLONE_URLS.sshUrl, + destinationPath, + }); + + assert.strictEqual(result.repository, null); + assert.strictEqual(result.remoteUrl, CLONE_URLS.sshUrl); + assert.deepStrictEqual(cloneCalls, [["clone", CLONE_URLS.sshUrl, "t3code"]]); + }).pipe( + Effect.provide( + makeLayer({ + provider: makeProvider({ + getRepositoryCloneUrls: (input) => + new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: input.repository, + detail: "gh is not authenticated", + }), + }), + git: { + execute: (input) => + Effect.sync(() => { + cloneCalls.push(input.args); + return processOutput(); + }), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("preserves destination probe failures instead of treating them as missing paths", () => { const fileSystemCause = PlatformError.systemError({ _tag: "PermissionDenied", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c..bf9fcb3311a 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -1,4 +1,5 @@ import * as NodeOS from "node:os"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -7,20 +8,41 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { + SourceControlProviderError, SourceControlRepositoryError, + type SourceControlCloneDefaultRepository, type SourceControlCloneRepositoryInput, type SourceControlCloneRepositoryResult, type SourceControlCloneProtocol, + type SourceControlDefaultRepositoryRemote, + type SourceControlDefaultRepositoryState, + type SourceControlGetDefaultRepositoryInput, type SourceControlProviderKind, type SourceControlPublishRepositoryInput, type SourceControlPublishRepositoryResult, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryInfo, + type SourceControlGetIssueInput, + type SourceControlIssue, + type SourceControlListIssuesInput, + type SourceControlListIssuesResult, + type SourceControlReference, + type SourceControlResolveReferencesInput, + type SourceControlResolveReferencesResult, type SourceControlRepositoryLookupInput, + type SourceControlSetDefaultRepositoryInput, } from "@t3tools/contracts"; +import { + detectSourceControlProviderFromGitRemoteUrl, + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + parseGitRemoteConfig, +} from "@t3tools/shared/git"; import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { MAX_REFERENCES_PER_REQUEST } from "./gitHubReferences.ts"; +import { makeReferenceCache } from "./referenceCache.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); @@ -36,6 +58,33 @@ export class SourceControlRepositoryService extends Context.Service< readonly publishRepository: ( input: SourceControlPublishRepositoryInput, ) => Effect.Effect; + /** + * Reads the candidates and current pick for the repository `gh` treats as + * this checkout's default. Both of these read and write the same git config + * `gh repo set-default` uses, so the two stay interchangeable. + */ + readonly getDefaultRepository: ( + input: SourceControlGetDefaultRepositoryInput, + ) => Effect.Effect; + readonly setDefaultRepository: ( + input: SourceControlSetDefaultRepositoryInput, + ) => Effect.Effect; + /** + * Issue browsing resolves the provider from the working directory's remote + * rather than an explicit `provider` field, so it keeps the richer + * `SourceControlProviderError` (which carries the CLI install/auth detail + * the picker's empty state renders). + */ + readonly listIssues: ( + input: SourceControlListIssuesInput, + ) => Effect.Effect; + readonly getIssue: ( + input: SourceControlGetIssueInput, + ) => Effect.Effect; + /** What the references in a body turn out to be, answered together. */ + readonly resolveReferences: ( + input: SourceControlResolveReferencesInput, + ) => Effect.Effect; } >()("t3/sourceControl/SourceControlRepositoryService") {} @@ -61,6 +110,7 @@ function toRepositoryInfo( nameWithOwner: urls.nameWithOwner, url: urls.url, sshUrl: urls.sshUrl, + ...(urls.parentNameWithOwner ? { parentNameWithOwner: urls.parentNameWithOwner } : {}), }; } @@ -77,6 +127,72 @@ function selectRemoteUrl( } } +/** + * `gh` records the default repository as `remote..gh-resolved`, which is + * also what `RepositoryIdentityResolver` reads when it decides which remote + * identifies a project. One `git config` read covers both the remote list and + * the current pick. + */ +const GH_RESOLVED_CONFIG_PATTERN = "^remote\\..*\\.(url|gh-resolved)$"; + +interface ParsedRemoteConfig { + readonly state: SourceControlDefaultRepositoryState; + /** Every remote carrying a pin, so a stale second pin gets cleared too. */ + readonly pinnedRemoteNames: ReadonlyArray; +} + +/** + * A remote URL names its repository, but only GitHub's `github.com` shape is + * parsed with case intact; every other host falls back to the normalized + * `host/owner/repo` key so Enterprise remotes still read as a repository rather + * than a URL. + */ +function repositoryNameWithOwnerFromRemoteUrl(url: string): string | null { + const gitHubNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url); + if (gitHubNameWithOwner) { + return gitHubNameWithOwner; + } + const segments = normalizeGitRemoteUrl(url).split("/"); + return segments.length > 1 ? segments.slice(1).join("/") : null; +} + +function parseRemoteConfig(stdout: string): ParsedRemoteConfig { + const entries = parseGitRemoteConfig(stdout); + const remotes: ReadonlyArray = entries.flatMap((entry) => + entry.url === null + ? [] + : [ + { + remoteName: entry.remoteName, + url: entry.url, + nameWithOwner: repositoryNameWithOwnerFromRemoteUrl(entry.url), + provider: detectSourceControlProviderFromGitRemoteUrl(entry.url)?.kind ?? "unknown", + }, + ], + ); + + const pinnedRemoteNames = entries + .filter((entry) => entry.ghResolved !== null) + .map((entry) => entry.remoteName); + const pinned = entries.find( + (entry) => entry.ghResolved !== null && remotes.some((r) => r.remoteName === entry.remoteName), + ); + + // `base` means the pinned remote's own repository; anything else names a + // different one that `gh` reaches through that remote. + const defaultRepositoryPath = + pinned && pinned.ghResolved !== "base" ? pinned.ghResolved : undefined; + + return { + state: { + remotes, + defaultRemoteName: pinned?.remoteName ?? null, + ...(defaultRepositoryPath ? { defaultRepositoryPath } : {}), + }, + pinnedRemoteNames, + }; +} + function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { return NodeOS.homedir(); @@ -177,6 +293,133 @@ export const make = Effect.gen(function* () { }, ); + const readRemoteConfig = Effect.fn("SourceControlRepositoryService.readRemoteConfig")(function* ( + cwd: string, + ) { + const result = yield* git.execute({ + operation: "SourceControlRepositoryService.getDefaultRepository", + cwd, + args: ["config", "--get-regexp", GH_RESOLVED_CONFIG_PATTERN], + // Exits non-zero when nothing matches, which just means no remotes. + allowNonZeroExit: true, + }); + return parseRemoteConfig(result.stdout); + }); + + const getDefaultRepository = Effect.fn("SourceControlRepositoryService.getDefaultRepository")( + function* (input: SourceControlGetDefaultRepositoryInput) { + return (yield* readRemoteConfig(input.cwd)).state; + }, + ); + + /** `gh` keeps exactly one pin, so clear any others before writing the pick. */ + const pinDefaultRemote = Effect.fn("SourceControlRepositoryService.pinDefaultRemote")( + function* (input: { + readonly cwd: string; + readonly remoteName: string | null; + readonly pinnedRemoteNames: ReadonlyArray; + }) { + for (const pinnedRemoteName of input.pinnedRemoteNames) { + if (pinnedRemoteName === input.remoteName) continue; + yield* git.execute({ + operation: "SourceControlRepositoryService.setDefaultRepository.unset", + cwd: input.cwd, + args: ["config", "--unset-all", `remote.${pinnedRemoteName}.gh-resolved`], + // Exits non-zero when the pin vanished between read and write. + allowNonZeroExit: true, + }); + } + + if (input.remoteName) { + yield* git.execute({ + operation: "SourceControlRepositoryService.setDefaultRepository.set", + cwd: input.cwd, + // `--replace-all`: `gh` adds resolutions rather than setting them, so + // the key can already hold several values, and a plain write refuses + // to overwrite those. + args: ["config", "--replace-all", `remote.${input.remoteName}.gh-resolved`, "base"], + }); + } + }, + ); + + const setDefaultRepository = Effect.fn("SourceControlRepositoryService.setDefaultRepository")( + function* (input: SourceControlSetDefaultRepositoryInput) { + const config = yield* readRemoteConfig(input.cwd); + const remoteName = input.remoteName?.trim() || null; + if (remoteName && !config.state.remotes.some((remote) => remote.remoteName === remoteName)) { + return yield* new SourceControlRepositoryError({ + operation: "setDefaultRepository", + provider: "unknown", + detail: "Choose a remote that exists in this repository.", + }); + } + + yield* pinDefaultRemote({ + cwd: input.cwd, + remoteName, + pinnedRemoteNames: config.pinnedRemoteNames, + }); + return yield* getDefaultRepository({ cwd: input.cwd }); + }, + ); + + /** + * Wires a freshly cloned fork to the repository it was forked from. The + * `upstream` remote is the easy half; pinning the default repository is the + * half that keeps the clone honest. `gh` picks a fork's parent as its base + * repository whenever several remotes exist, so adding `upstream` without a + * pin would silently retarget `gh pr create` and `gh issue list` at the + * parent project, whichever repository the user actually meant. + */ + const wireForkUpstream = Effect.fn("SourceControlRepositoryService.wireForkUpstream")( + function* (input: { + readonly cwd: string; + readonly provider: SourceControlProviderKind; + readonly parentNameWithOwner: string; + readonly protocol: SourceControlCloneProtocol | undefined; + readonly defaultRepository: SourceControlCloneDefaultRepository; + }) { + const parent = yield* lookupRepository({ + provider: input.provider, + repository: input.parentNameWithOwner, + cwd: input.cwd, + }); + const remoteUrl = selectRemoteUrl(parent, input.protocol); + const clonedRemoteName = yield* git.resolvePrimaryRemoteName(input.cwd); + const remoteName = yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: "upstream", + url: remoteUrl, + }); + + // The remotes were just created here, so the pick needs no re-validation; + // a fresh clone also has nothing pinned to clear. + yield* pinDefaultRemote({ + cwd: input.cwd, + remoteName: input.defaultRepository === "parent" ? remoteName : clonedRemoteName, + pinnedRemoteNames: [], + }); + + // `gh repo clone` leaves a fetched upstream behind, so `upstream/main` + // resolves immediately. A fork shares history with its parent, so this is + // usually a small incremental fetch — and the remote is already wired up, + // so a slow or failing network here must not undo any of the above. + yield* git.fetchRemote({ cwd: input.cwd, remoteName }).pipe( + Effect.tapError((cause) => + Effect.logWarning("Fetching the fork upstream remote failed", { + cwd: input.cwd, + remoteName, + cause, + }), + ), + Effect.ignore, + ); + + return { remoteName, nameWithOwner: parent.nameWithOwner, remoteUrl }; + }, + ); + const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( input: SourceControlCloneRepositoryInput, ) { @@ -186,13 +429,19 @@ export const make = Effect.gen(function* () { let provider: SourceControlProviderKind = input.provider ?? "unknown"; if (input.provider && input.repository) { + provider = input.provider; repository = yield* lookupRepository({ provider: input.provider, repository: input.repository, cwd: preparedDestination.parentPath, - }); - remoteUrl = selectRemoteUrl(repository, input.protocol); - provider = input.provider; + }).pipe( + // A clone URL the client already resolved is enough to clone from. A + // failed lookup then only costs the fork wiring below, not the clone. + Effect.catch((cause) => (remoteUrl ? Effect.succeed(null) : Effect.fail(cause))), + ); + if (repository) { + remoteUrl = selectRemoteUrl(repository, input.protocol); + } } if (!remoteUrl) { @@ -211,10 +460,37 @@ export const make = Effect.gen(function* () { maxOutputBytes: 256 * 1024, }); + const parentNameWithOwner = repository?.parentNameWithOwner ?? null; + const upstream = !parentNameWithOwner + ? null + : yield* wireForkUpstream({ + cwd: preparedDestination.destinationPath, + provider, + parentNameWithOwner, + protocol: input.protocol, + // `gh repo clone` would pick the parent here, but T3 identifies a + // checkout by the remote its branch tracks: pinning the fork keeps + // the two agreeing for work on the fork, and choosing the parent + // stays one keystroke away for contributing upstream. + defaultRepository: input.defaultRepository ?? "cloned", + }).pipe( + // The clone is already on disk and usable; a fork whose parent could + // not be wired up is a warning, not a failed clone. + Effect.tapError((cause) => + Effect.logWarning("Fork upstream wiring failed after clone", { + cwd: preparedDestination.destinationPath, + parent: parentNameWithOwner, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + return { cwd: preparedDestination.destinationPath, remoteUrl, repository, + ...(upstream ? { upstream } : {}), }; }); @@ -275,7 +551,90 @@ export const make = Effect.gen(function* () { }, ); + const listIssues = Effect.fn("SourceControlRepositoryService.listIssues")(function* ( + input: SourceControlListIssuesInput, + ) { + const provider = yield* providers.resolve({ cwd: input.cwd }); + const issues = yield* provider.listIssues({ + cwd: input.cwd, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }); + return { provider: provider.kind, issues } satisfies SourceControlListIssuesResult; + }); + + const getIssue = Effect.fn("SourceControlRepositoryService.getIssue")(function* ( + input: SourceControlGetIssueInput, + ) { + const provider = yield* providers.resolve({ cwd: input.cwd }); + return yield* provider.getIssue({ cwd: input.cwd, number: input.number }); + }); + + /** + * The host this checkout is read against. Taken from the remotes rather than the caller: a + * hostname is where credentials get sent, which is not a body's text to decide. + */ + const resolveReferenceHost = Effect.fn("SourceControlRepositoryService.resolveReferenceHost")( + function* (cwd: string) { + const config = yield* readRemoteConfig(cwd).pipe(Effect.orElseSucceed(() => null)); + const remotes = config?.state.remotes ?? []; + const preferred = + remotes.find((remote) => remote.remoteName === config?.state.defaultRemoteName) ?? + remotes.find((remote) => remote.remoteName === "origin") ?? + remotes[0]; + const host = preferred ? normalizeGitRemoteUrl(preferred.url).split("/")[0] : undefined; + return host && host.length > 0 ? host : "github.com"; + }, + ); + + const referenceCache = makeReferenceCache(); + + const resolveReferences = Effect.fn("SourceControlRepositoryService.resolveReferences")( + function* (input: SourceControlResolveReferencesInput) { + const provider = yield* providers.resolve({ cwd: input.cwd }); + // One answer per reference however often a body names it, and a ceiling on the rest. + const unique = new Map(); + for (const reference of input.references) { + unique.set(`${reference.repository.toLowerCase()}#${reference.number}`, reference); + if (unique.size >= MAX_REFERENCES_PER_REQUEST) break; + } + if (unique.size === 0) { + const host = yield* resolveReferenceHost(input.cwd); + return { + provider: provider.kind, + host, + references: [], + } satisfies SourceControlResolveReferencesResult; + } + + const host = yield* resolveReferenceHost(input.cwd); + const now = yield* Clock.currentTimeMillis; + const { cached, unanswered } = referenceCache.read(now, host, [...unique.values()]); + if (unanswered.length === 0) { + return { + provider: provider.kind, + host, + references: cached, + } satisfies SourceControlResolveReferencesResult; + } + + const resolved = yield* provider.resolveReferences({ + cwd: input.cwd, + host, + references: unanswered, + }); + referenceCache.write(now, host, resolved); + return { + provider: provider.kind, + host, + references: [...cached, ...resolved], + } satisfies SourceControlResolveReferencesResult; + }, + ); + return SourceControlRepositoryService.of({ + listIssues, + getIssue, + resolveReferences, lookupRepository: (input) => lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), cloneRepository: (input) => @@ -284,6 +643,10 @@ export const make = Effect.gen(function* () { ), publishRepository: (input) => publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), + getDefaultRepository: (input) => + getDefaultRepository(input).pipe(mapRepositoryError("getDefaultRepository", "unknown")), + setDefaultRepository: (input) => + setDefaultRepository(input).pipe(mapRepositoryError("setDefaultRepository", "unknown")), }); }); diff --git a/apps/server/src/sourceControl/gitHubIssues.ts b/apps/server/src/sourceControl/gitHubIssues.ts new file mode 100644 index 00000000000..f9124e5f994 --- /dev/null +++ b/apps/server/src/sourceControl/gitHubIssues.ts @@ -0,0 +1,165 @@ +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +export interface NormalizedGitHubIssueSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly state: "open" | "closed"; + readonly labels: ReadonlyArray; + readonly updatedAt: Option.Option; +} + +export interface NormalizedGitHubIssueComment { + readonly author: string | null; + readonly body: string; +} + +export interface NormalizedGitHubIssue { + readonly number: number; + readonly title: string; + readonly url: string; + readonly state: "open" | "closed"; + readonly repository: string | null; + readonly author: string | null; + readonly body: string; + readonly comments: ReadonlyArray; +} + +const GitHubActorSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitHubIssueLabelSchema = Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitHubIssueSummarySchema = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + labels: Schema.optional(Schema.NullOr(Schema.Array(GitHubIssueLabelSchema))), + updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), +}); + +const GitHubIssueCommentSchema = Schema.Struct({ + author: Schema.optional(Schema.NullOr(GitHubActorSchema)), + body: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const GitHubIssueSchema = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(GitHubActorSchema)), + comments: Schema.optional(Schema.NullOr(Schema.Array(GitHubIssueCommentSchema))), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeGitHubIssueState(state: string | null | undefined): "open" | "closed" { + return state?.trim().toUpperCase() === "CLOSED" ? "closed" : "open"; +} + +/** + * `gh issue view` does not report the repository, but every issue URL carries + * it (`https://github.com/owner/repo/issues/123`). Reading it back here keeps + * the issue context block self-describing without a second `gh` call. + */ +export function parseRepositoryNameFromIssueUrl(url: string): string | null { + try { + const segments = new URL(url).pathname.split("/").filter(Boolean); + const owner = segments[0]; + const repository = segments[1]; + return owner && repository ? `${owner}/${repository}` : null; + } catch { + return null; + } +} + +function normalizeGitHubIssueSummary( + raw: Schema.Schema.Type, +): NormalizedGitHubIssueSummary { + const labels: Array = []; + for (const label of raw.labels ?? []) { + const name = trimOptionalString(label.name); + if (name) { + labels.push(name); + } + } + + return { + number: raw.number, + title: raw.title, + url: raw.url, + state: normalizeGitHubIssueState(raw.state), + labels, + updatedAt: raw.updatedAt ?? Option.none(), + }; +} + +function normalizeGitHubIssue( + raw: Schema.Schema.Type, +): NormalizedGitHubIssue { + return { + number: raw.number, + title: raw.title, + url: raw.url, + state: normalizeGitHubIssueState(raw.state), + repository: parseRepositoryNameFromIssueUrl(raw.url), + author: trimOptionalString(raw.author?.login), + body: raw.body?.trim() ?? "", + comments: (raw.comments ?? []).map((comment) => ({ + author: trimOptionalString(comment.author?.login), + body: comment.body?.trim() ?? "", + })), + }; +} + +const decodeGitHubIssueArray = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeGitHubIssueRecord = decodeJsonResult(GitHubIssueSchema); +const decodeGitHubIssueSummaryEntry = Schema.decodeUnknownExit(GitHubIssueSummarySchema); + +/** + * Individually-invalid rows are skipped rather than failing the whole list, so + * a version-drifted `gh` can never blank out the issue picker. + */ +export function decodeGitHubIssueListJson( + raw: string, +): Result.Result, Cause.Cause> { + const result = decodeGitHubIssueArray(raw); + if (Result.isSuccess(result)) { + const issues: Array = []; + for (const entry of result.success) { + const decodedEntry = decodeGitHubIssueSummaryEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + issues.push(normalizeGitHubIssueSummary(decodedEntry.value)); + } + return Result.succeed(issues); + } + return Result.fail(result.failure); +} + +export function decodeGitHubIssueJson( + raw: string, +): Result.Result> { + const result = decodeGitHubIssueRecord(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeGitHubIssue(result.success)); + } + return Result.fail(result.failure); +} diff --git a/apps/server/src/sourceControl/gitHubReferences.test.ts b/apps/server/src/sourceControl/gitHubReferences.test.ts new file mode 100644 index 00000000000..09a5242004a --- /dev/null +++ b/apps/server/src/sourceControl/gitHubReferences.test.ts @@ -0,0 +1,182 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildGitHubReferenceQuery, + decodeGitHubReferenceResponseJson, + MAX_REFERENCES_PER_REQUEST, + splitRepository, +} from "./gitHubReferences.ts"; + +const REFERENCES = [ + { repository: "pingdotgg/t3code", number: 6039 }, + { repository: "pingdotgg/t3code", number: 2243 }, + { repository: "other/repo", number: 7 }, +]; + +function build(references: ReadonlyArray<{ repository: string; number: number }>) { + const built = buildGitHubReferenceQuery(references); + if (built === null) throw new Error("expected a query"); + return built; +} + +describe("buildGitHubReferenceQuery", () => { + it("asks one repository field per repository, whatever the order they are written in", () => { + const built = build(REFERENCES); + + expect(built.variables).toEqual({ + o0: "pingdotgg", + n0: "t3code", + o1: "other", + n1: "repo", + }); + // One repository field per repository, one number field inside it, and no reference sharing + // an address with another — the spelling of the aliases is the builder's own business. + expect(new Set(built.aliases.map((alias) => alias.repositoryAlias))).toHaveLength(2); + expect( + new Set(built.aliases.map((alias) => `${alias.repositoryAlias}/${alias.numberAlias}`)), + ).toHaveLength(3); + }); + + it("carries the repository as a variable, never as part of the document", () => { + const built = build([{ repository: 'ev"il/re"po', number: 1 }]); + + expect(built.query).not.toContain("ev"); + expect(built.variables.o0).toBe('ev"il'); + }); + + it("skips anything that is not owner/repo, and says nothing when none are left", () => { + expect(buildGitHubReferenceQuery([{ repository: "justaname", number: 1 }])).toBeNull(); + expect(buildGitHubReferenceQuery([])).toBeNull(); + expect(splitRepository("owner/repo/extra")).toBeNull(); + }); + + it("stops at the ceiling rather than pricing a whole changelog into one request", () => { + const many = Array.from({ length: MAX_REFERENCES_PER_REQUEST + 10 }, (_, index) => ({ + repository: "pingdotgg/t3code", + number: index + 1, + })); + + expect(build(many).aliases).toHaveLength(MAX_REFERENCES_PER_REQUEST); + }); +}); + +describe("decodeGitHubReferenceResponseJson", () => { + function decode(body: unknown) { + const decoded = decodeGitHubReferenceResponseJson( + JSON.stringify(body), + build(REFERENCES).aliases, + ); + if (!Result.isSuccess(decoded)) throw new Error("expected a decoded body"); + return decoded.success; + } + + it("reads a pull request, an issue, and the null the host called NOT_FOUND", () => { + const resolved = decode({ + data: { + r0: { + i0: { __typename: "PullRequest", title: "A fix", url: "https://x/1", state: "MERGED" }, + i1: null, + }, + r1: { i0: { __typename: "Issue", title: "A bug", url: "https://x/2", state: "OPEN" } }, + }, + errors: [{ type: "NOT_FOUND", path: ["r0", "i1"] }], + }); + + expect(resolved).toEqual([ + { + repository: "pingdotgg/t3code", + number: 6039, + kind: "pull-request", + title: "A fix", + state: "merged", + url: "https://x/1", + }, + { + repository: "pingdotgg/t3code", + number: 2243, + kind: null, + title: null, + state: null, + url: null, + }, + { + repository: "other/repo", + number: 7, + kind: "issue", + title: "A bug", + state: "open", + url: "https://x/2", + }, + ]); + }); + + it("reads a draft as its own state, which is what the badge says", () => { + const resolved = decode({ + data: { + r0: { + i0: { + __typename: "PullRequest", + title: "WIP", + url: "https://x/1", + state: "OPEN", + isDraft: true, + }, + }, + }, + }); + + expect(resolved[0]?.state).toBe("draft"); + }); + + it("says nothing about a null the host did not explain", () => { + const resolved = decode({ data: { r0: { i0: null, i1: null } } }); + + expect(resolved).toEqual([]); + }); + + it("does not call a reference missing when the host merely refused to show it", () => { + // FORBIDDEN is SAML, an IP allowlist, or a token scoped elsewhere — all things the reader may + // well be able to open themselves, so the link stays an ordinary one. + const resolved = decode({ + data: { r0: { i0: null, i1: null }, r1: { i0: null } }, + errors: [ + { type: "FORBIDDEN", path: ["r0", "i0"] }, + { type: "NOT_FOUND", path: ["r0", "i1"] }, + { type: "FORBIDDEN", path: ["r1"] }, + ], + }); + + expect(resolved).toEqual([ + { + repository: "pingdotgg/t3code", + number: 2243, + kind: null, + title: null, + state: null, + url: null, + }, + ]); + }); + + it("marks every number under a repository the host has nothing under", () => { + const resolved = decode({ + data: { + r0: null, + r1: { i0: { __typename: "Issue", title: "A bug", url: "u", state: "OPEN" } }, + }, + errors: [{ type: "NOT_FOUND", path: ["r0"] }], + }); + + expect(resolved.filter((reference) => reference.kind === null).map((r) => r.number)).toEqual([ + 6039, 2243, + ]); + expect(resolved[2]?.kind).toBe("issue"); + }); + + it("fails on a body that is not an answer at all, so the caller can say why", () => { + const decoded = decodeGitHubReferenceResponseJson("", build(REFERENCES).aliases); + + expect(Result.isSuccess(decoded)).toBe(false); + }); +}); diff --git a/apps/server/src/sourceControl/gitHubReferences.ts b/apps/server/src/sourceControl/gitHubReferences.ts new file mode 100644 index 00000000000..8b16dd5f535 --- /dev/null +++ b/apps/server/src/sourceControl/gitHubReferences.ts @@ -0,0 +1,217 @@ +/** + * Resolving the references a body names in one request: repositories become aliased fields, + * numbers aliased fields inside them, and `issueOrPullRequest` says which kind each is. + * + * A partial answer is the normal answer — the host nulls what it cannot show — and `gh` exits + * non-zero for it, so the caller reads this body rather than the exit code. + */ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +export interface GitHubReferenceRequest { + readonly repository: string; + readonly number: number; +} + +export interface GitHubResolvedReference { + readonly repository: string; + readonly number: number; + /** Null when the host has nothing under this number. */ + readonly kind: "issue" | "pull-request" | null; + readonly title: string | null; + readonly state: "open" | "draft" | "closed" | "merged" | null; + readonly url: string | null; +} + +/** GitHub scores a query before running it. A body citing more than this is quoting a changelog. */ +export const MAX_REFERENCES_PER_REQUEST = 40; + +const REFERENCE_FIELDS = `__typename + ... on Issue { title url state } + ... on PullRequest { title url state isDraft }`; + +interface ReferenceAlias { + readonly reference: GitHubReferenceRequest; + readonly repositoryAlias: string; + readonly numberAlias: string; +} + +/** `owner/repo`, split the way the host addresses it. */ +export function splitRepository(repository: string): { owner: string; name: string } | null { + const segments = repository.split("/"); + const owner = segments[0]?.trim(); + const name = segments[1]?.trim(); + return segments.length === 2 && owner && name ? { owner, name } : null; +} + +/** + * The query, its variables, and where each reference's answer will be found. Owner and name travel + * as variables because they are words a body wrote; numbers are integers by the time they arrive. + */ +export function buildGitHubReferenceQuery(references: ReadonlyArray): { + readonly query: string; + readonly variables: Record; + readonly aliases: ReadonlyArray; +} | null { + const byRepository = new Map }>(); + const aliases: Array = []; + + for (const reference of references.slice(0, MAX_REFERENCES_PER_REQUEST)) { + const split = splitRepository(reference.repository); + if (split === null) continue; + const key = reference.repository.toLowerCase(); + const group = byRepository.get(key) ?? { ...split, numbers: [] }; + if (!group.numbers.includes(reference.number)) group.numbers.push(reference.number); + byRepository.set(key, group); + aliases.push({ + reference, + repositoryAlias: `r${[...byRepository.keys()].indexOf(key)}`, + numberAlias: `i${group.numbers.indexOf(reference.number)}`, + }); + } + if (byRepository.size === 0) return null; + + const variables: Record = {}; + const declarations: Array = []; + const fields: Array = []; + let index = 0; + for (const group of byRepository.values()) { + variables[`o${index}`] = group.owner; + variables[`n${index}`] = group.name; + declarations.push(`$o${index}: String!, $n${index}: String!`); + const numbers = group.numbers + .map( + (number, numberIndex) => + `i${numberIndex}: issueOrPullRequest(number: ${number}) { ${REFERENCE_FIELDS} }`, + ) + .join("\n "); + fields.push( + `r${index}: repository(owner: $o${index}, name: $n${index}) {\n ${numbers}\n }`, + ); + index += 1; + } + + return { + query: `query(${declarations.join(", ")}) {\n ${fields.join("\n ")}\n}`, + variables, + aliases, + }; +} + +const ReferenceNodeSchema = Schema.Struct({ + __typename: Schema.optional(Schema.NullOr(Schema.String)), + title: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), +}); + +/** + * The errors beside the data are part of the answer: `NOT_FOUND` means nothing is under that + * number, while `FORBIDDEN` — SAML, an IP allowlist, a token scoped elsewhere — means the host + * declined to say, about something the reader may well be able to open themselves. + */ +const ReferenceErrorSchema = Schema.Struct({ + type: Schema.optional(Schema.NullOr(Schema.String)), + path: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), +}); + +const ReferenceResponseSchema = Schema.Struct({ + data: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.NullOr(Schema.Unknown)))), + errors: Schema.optional(Schema.NullOr(Schema.Array(ReferenceErrorSchema))), +}); + +const decodeReferenceResponse = decodeJsonResult(ReferenceResponseSchema); + +/** The error type filed against each failed path, keyed as `r0` or `r0/i1`. */ +function errorTypesByPath( + errors: ReadonlyArray>, +): Map { + const byPath = new Map(); + for (const error of errors) { + const path = (error.path ?? []).filter((segment) => typeof segment === "string"); + const type = error.type?.trim().toUpperCase(); + if (path.length === 0 || type === undefined) continue; + byPath.set(path.join("/"), type); + } + return byPath; +} +const decodeReferenceNode = Schema.decodeUnknownExit(ReferenceNodeSchema); + +function normalizeState( + raw: Schema.Schema.Type, + kind: "issue" | "pull-request", +): "open" | "draft" | "closed" | "merged" | null { + const state = raw.state?.trim().toUpperCase(); + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + if (state !== "OPEN") return null; + return kind === "pull-request" && raw.isDraft === true ? "draft" : "open"; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** + * The answers, in the order they were asked for. A reference is left out unless the host was clear + * about it, since an omitted answer renders as an ordinary link while a wrong one calls a + * reference that exists a mistake. + */ +export function decodeGitHubReferenceResponseJson( + raw: string, + aliases: ReadonlyArray, +): Result.Result, Cause.Cause> { + const decoded = decodeReferenceResponse(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const data = decoded.success.data ?? {}; + const errorTypes = errorTypesByPath(decoded.success.errors ?? []); + + const resolved: Array = []; + for (const alias of aliases) { + // Unresolved at either level, and whether it came back as a null or not at all, reads the + // same. Only the host's own `NOT_FOUND` makes it a statement about the reference. + const nothingThere = + errorTypes.get(`${alias.repositoryAlias}/${alias.numberAlias}`) === "NOT_FOUND" || + errorTypes.get(alias.repositoryAlias) === "NOT_FOUND"; + const repository = data[alias.repositoryAlias]; + const numbers = + repository !== null && typeof repository === "object" + ? (repository as Record) + : undefined; + const node = numbers?.[alias.numberAlias]; + if (node === null || node === undefined) { + if (nothingThere) { + resolved.push({ + repository: alias.reference.repository, + number: alias.reference.number, + kind: null, + title: null, + state: null, + url: null, + }); + } + continue; + } + const decodedNode = decodeReferenceNode(node); + // A shape this does not recognise is not a missing issue; say nothing about it. + if (Exit.isFailure(decodedNode)) continue; + const typename = decodedNode.value.__typename?.trim(); + const kind = + typename === "PullRequest" ? "pull-request" : typename === "Issue" ? "issue" : null; + if (kind === null) continue; + resolved.push({ + repository: alias.reference.repository, + number: alias.reference.number, + kind, + title: trimmed(decodedNode.value.title), + state: normalizeState(decodedNode.value, kind), + url: trimmed(decodedNode.value.url), + }); + } + return Result.succeed(resolved); +} diff --git a/apps/server/src/sourceControl/referenceCache.test.ts b/apps/server/src/sourceControl/referenceCache.test.ts new file mode 100644 index 00000000000..1491bff75fc --- /dev/null +++ b/apps/server/src/sourceControl/referenceCache.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { makeReferenceCache } from "./referenceCache.ts"; + +const REFERENCE = { repository: "pingdotgg/t3code", number: 6039 }; + +function answer(overrides: Partial<{ kind: "issue" | "pull-request" | null }> = {}) { + return { + repository: "pingdotgg/t3code", + number: 6039, + kind: "pull-request" as "issue" | "pull-request" | null, + title: "A fix", + state: "open" as const, + url: "https://github.com/pingdotgg/t3code/pull/6039", + ...overrides, + }; +} + +describe("makeReferenceCache", () => { + it("answers a reference a neighbouring body already resolved", () => { + const cache = makeReferenceCache(); + cache.write(1000, "github.com", [answer()]); + + const read = cache.read(1000, "github.com", [REFERENCE]); + + expect(read.cached).toEqual([answer()]); + expect(read.unanswered).toEqual([]); + }); + + it("asks again once an answer is stale, and keeps a missing one longer", () => { + const cache = makeReferenceCache({ resolvedTtlMs: 100, missingTtlMs: 500 }); + cache.write(0, "github.com", [answer(), answer({ kind: null })]); + + // The second write replaced the first, so the entry now carries the missing lifetime. + expect(cache.read(300, "github.com", [REFERENCE]).cached).toHaveLength(1); + expect(cache.read(600, "github.com", [REFERENCE]).unanswered).toEqual([REFERENCE]); + }); + + it("keeps one host's answers away from another's, which spell numbers the same", () => { + const cache = makeReferenceCache(); + cache.write(0, "github.com", [answer()]); + + expect(cache.read(0, "github.acme.test", [REFERENCE]).unanswered).toEqual([REFERENCE]); + }); + + it("says nothing about a reference nobody answered", () => { + const cache = makeReferenceCache(); + cache.write(0, "github.com", []); + + expect(cache.read(0, "github.com", [REFERENCE])).toEqual({ + cached: [], + unanswered: [REFERENCE], + }); + }); + + it("drops the oldest answers rather than growing without a bound", () => { + const cache = makeReferenceCache({ capacity: 2 }); + for (const number of [1, 2, 3]) { + cache.write(0, "github.com", [{ ...answer(), number }]); + } + + expect(cache.size()).toBe(2); + expect( + cache.read(0, "github.com", [{ repository: REFERENCE.repository, number: 1 }]).unanswered, + ).toHaveLength(1); + expect( + cache.read(0, "github.com", [{ repository: REFERENCE.repository, number: 3 }]).cached, + ).toHaveLength(1); + }); +}); diff --git a/apps/server/src/sourceControl/referenceCache.ts b/apps/server/src/sourceControl/referenceCache.ts new file mode 100644 index 00000000000..efa481126c9 --- /dev/null +++ b/apps/server/src/sourceControl/referenceCache.ts @@ -0,0 +1,76 @@ +/** + * What is already known about a reference, shared across the bodies of one panel so each comment + * does not re-ask what its neighbour just resolved. A resolved reference expires sooner than a + * missing one. A plain map with the clock passed in, since a batch is filled and read as a batch. + */ +import type { SourceControlReference, SourceControlResolvedReference } from "@t3tools/contracts"; + +export interface ReferenceCacheOptions { + readonly resolvedTtlMs?: number; + readonly missingTtlMs?: number; + readonly capacity?: number; +} + +export interface ReferenceCache { + /** Splits a batch into what is already known and what still has to be asked. */ + readonly read: ( + now: number, + host: string, + references: ReadonlyArray, + ) => { + readonly cached: ReadonlyArray; + readonly unanswered: ReadonlyArray; + }; + readonly write: ( + now: number, + host: string, + resolved: ReadonlyArray, + ) => void; + readonly size: () => number; +} + +const RESOLVED_TTL_MS = 5 * 60_000; +const MISSING_TTL_MS = 10 * 60_000; +const CAPACITY = 2048; + +/** The host is part of the key: an Enterprise install and github.com spell numbers alike. */ +function cacheKey(host: string, reference: SourceControlReference): string { + return `${host.toLowerCase()} ${reference.repository.toLowerCase()}#${reference.number}`; +} + +export function makeReferenceCache(options: ReferenceCacheOptions = {}): ReferenceCache { + const resolvedTtlMs = options.resolvedTtlMs ?? RESOLVED_TTL_MS; + const missingTtlMs = options.missingTtlMs ?? MISSING_TTL_MS; + const capacity = options.capacity ?? CAPACITY; + const entries = new Map< + string, + { readonly reference: SourceControlResolvedReference; readonly expiresAt: number } + >(); + + return { + read: (now, host, references) => { + const cached: Array = []; + const unanswered: Array = []; + for (const reference of references) { + const entry = entries.get(cacheKey(host, reference)); + if (entry && entry.expiresAt > now) cached.push(entry.reference); + else unanswered.push(reference); + } + return { cached, unanswered }; + }, + write: (now, host, resolved) => { + for (const reference of resolved) { + if (entries.size >= capacity) { + // Insertion order is age order here, so the oldest entry is the first key. + const oldest = entries.keys().next(); + if (!oldest.done) entries.delete(oldest.value); + } + entries.set(cacheKey(host, reference), { + reference, + expiresAt: now + (reference.kind === null ? missingTtlMs : resolvedTtlMs), + }); + } + }, + size: () => entries.size, + }; +} diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 47d91e4516e..1a706e7d984 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -28,6 +29,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; +import type { TerminalBrowserOpenOwner } from "../preview/TerminalBrowserOpen.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -213,7 +215,10 @@ interface CreateManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; + resolveWorkspaceEnvironment?: (workspacePath: string) => Effect.Effect>; ptyAdapter?: FakePtyAdapter; + registerBrowserOpen?: (input: TerminalBrowserOpenOwner) => Effect.Effect>; + unregisterBrowserOpen?: (input: TerminalBrowserOpenOwner) => Effect.Effect; } interface ManagerFixture { @@ -255,6 +260,15 @@ const createManager = ( ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), + ...(options.resolveWorkspaceEnvironment !== undefined + ? { resolveWorkspaceEnvironment: options.resolveWorkspaceEnvironment } + : {}), + ...(options.registerBrowserOpen !== undefined + ? { registerBrowserOpen: options.registerBrowserOpen } + : {}), + ...(options.unregisterBrowserOpen !== undefined + ? { unregisterBrowserOpen: options.unregisterBrowserOpen } + : {}), }); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => @@ -1509,6 +1523,62 @@ it.layer( }), ); + it.effect("injects browser-open capture into terminals and revokes it on exit", () => + Effect.gen(function* () { + const registered: TerminalBrowserOpenOwner[] = []; + const unregistered: TerminalBrowserOpenOwner[] = []; + const { manager, ptyAdapter } = yield* createManager(5, { + registerBrowserOpen: (owner) => + Effect.sync(() => { + registered.push(owner); + return { + BROWSER: "/tmp/t3-browser-open.js", + T3CODE_TERMINAL_BROWSER_OPEN_SHIM_DIR: "/tmp/t3-browser-open-bin", + T3CODE_TERMINAL_BROWSER_OPEN_TOKEN: "terminal-token", + }; + }), + unregisterBrowserOpen: (owner) => + Effect.sync(() => { + unregistered.push(owner); + }), + }); + + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + const process = ptyAdapter.processes[0]; + expect(spawnInput).toBeDefined(); + expect(process).toBeDefined(); + if (!spawnInput || !process) return; + + expect(registered).toEqual([{ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }]); + expect(spawnInput.env.BROWSER).toBe("/tmp/t3-browser-open.js"); + expect(spawnInput.env.PATH?.split(":")[0]).toBe("/tmp/t3-browser-open-bin"); + expect(spawnInput.env.T3CODE_TERMINAL_BROWSER_OPEN_TOKEN).toBe("terminal-token"); + + process.emitExit({ exitCode: 0, signal: 0 }); + yield* waitFor(Effect.sync(() => unregistered.length === 1)); + expect(unregistered).toEqual([{ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }]); + }), + ); + + it.effect("preserves an explicitly configured browser", () => + Effect.gen(function* () { + let registrations = 0; + const { manager, ptyAdapter } = yield* createManager(5, { + env: { BROWSER: "none" }, + registerBrowserOpen: () => + Effect.sync(() => { + registrations += 1; + return { BROWSER: "/tmp/t3-browser-open.js" }; + }), + }); + + yield* manager.open(openInput()); + expect(ptyAdapter.spawnInputs[0]?.env.BROWSER).toBe("none"); + expect(registrations).toBe(0); + }), + ); + it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; @@ -1595,6 +1665,29 @@ it.layer( }), ); + it.effect("injects the workspace port after client-provided runtime env", () => + Effect.gen(function* () { + const resolvedPaths: string[] = []; + const { baseDir, manager, ptyAdapter } = yield* createManager(5, { + resolveWorkspaceEnvironment: (workspacePath) => + Effect.sync(() => { + resolvedPaths.push(workspacePath); + return { T3CODE_WORKSPACE_PORT: "24120" }; + }), + }); + yield* manager.open( + openInput({ + cwd: baseDir, + worktreePath: baseDir, + env: { T3CODE_WORKSPACE_PORT: "9999" }, + }), + ); + + assert.deepStrictEqual(resolvedPaths, [baseDir]); + assert.equal(ptyAdapter.spawnInputs[0]?.env.T3CODE_WORKSPACE_PORT, "24120"); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; @@ -1610,6 +1703,33 @@ it.layer( }), ); + it.effect("runs a terminal command through the shell and reports its exit status", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const { manager, ptyAdapter } = yield* createManager(5, { + shellResolver: () => "/bin/zsh", + }); + const exited = yield* Deferred.make>(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "exited" ? Deferred.succeed(exited, event).pipe(Effect.asVoid) : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* manager.openCommand({ + ...openInput({ terminalId: "setup-setup" }), + command: "bun install", + }); + expect(ptyAdapter.spawnInputs[0]?.args).toEqual(["-o", "nopromptsp", "-ic", "bun install"]); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + process?.emitExit({ exitCode: 9, signal: null }); + const exitEvent = yield* Deferred.await(exited); + expect(exitEvent.exitCode).toBe(9); + expect(exitEvent.exitSignal).toBeNull(); + }), + ); + it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 64c2dbb913f..7262236765c 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -34,6 +34,7 @@ import { } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { mergePathValues } from "@t3tools/shared/shell"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; @@ -59,6 +60,8 @@ import { } from "../observability/Metrics.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; +import * as TerminalBrowserOpen from "../preview/TerminalBrowserOpen.ts"; +import * as WorkspacePortAllocator from "../workspace/WorkspacePortAllocator.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; export { @@ -136,6 +139,15 @@ export class TerminalManager extends Context.Service< input: TerminalOpenInput, ) => Effect.Effect; + /** + * Open a terminal whose shell executes one finite command and exits with + * that command's status. This is server-only; interactive clients use + * {@link open} and {@link write} instead. + */ + readonly openCommand: ( + input: TerminalCommandOpenInput, + ) => Effect.Effect; + /** * Attach to a terminal and stream its initial snapshot followed by live events. * @@ -236,6 +248,11 @@ export interface ShellCandidate { export interface TerminalStartInput extends TerminalOpenInput { cols: number; rows: number; + command?: string; +} + +export interface TerminalCommandOpenInput extends TerminalOpenInput { + readonly command: string; } export interface TerminalSessionState { @@ -263,6 +280,7 @@ export interface TerminalSessionState { /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ childCommandLabel: string | null; runtimeEnv: Record | null; + launchCommand: string | null; } interface PersistHistoryRequest { @@ -573,6 +591,24 @@ function resolveShellCandidates( ]); } +function shellCandidateForCommand( + candidate: ShellCandidate, + command: string, + platform: NodeJS.Platform, +): ShellCandidate { + const shellName = basenameForPlatform(candidate.shell, platform).toLowerCase(); + const existingArgs = candidate.args ?? []; + if (platform === "win32") { + if (shellName === "pwsh.exe" || shellName === "powershell.exe") { + return { ...candidate, args: [...existingArgs, "-Command", command] }; + } + if (shellName === "cmd.exe") { + return { ...candidate, args: [...existingArgs, "/d", "/s", "/c", command] }; + } + } + return { ...candidate, args: [...existingArgs, "-ic", command] }; +} + function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean { const queue: unknown[] = [error]; const seen = new Set(); @@ -1099,6 +1135,35 @@ function createTerminalSpawnEnv( return stripAppImageRuntimeEnv(spawnEnv); } +function hasTerminalEnvKey( + env: NodeJS.ProcessEnv, + expectedKey: string, + platform: NodeJS.Platform, +): boolean { + if (platform !== "win32") return env[expectedKey] !== undefined; + const normalizedExpectedKey = expectedKey.toUpperCase(); + return Object.entries(env).some( + ([key, value]) => value !== undefined && key.toUpperCase() === normalizedExpectedKey, + ); +} + +function prependTerminalPath( + env: NodeJS.ProcessEnv, + preferredPath: string, + platform: NodeJS.Platform, +): NodeJS.ProcessEnv { + const nextEnv = { ...env }; + let inheritedPath: string | undefined; + for (const [key, value] of Object.entries(nextEnv)) { + if (key.toUpperCase() !== "PATH") continue; + inheritedPath ??= value; + delete nextEnv[key]; + } + const mergedPath = mergePathValues(preferredPath, inheritedPath, platform); + if (mergedPath !== undefined) nextEnv.PATH = mergedPath; + return nextEnv; +} + function normalizedRuntimeEnv( env: Record | undefined, ): Record | null { @@ -1118,6 +1183,9 @@ interface TerminalManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; + resolveWorkspaceEnvironment?: ( + workspacePath: string, + ) => Effect.Effect, WorkspacePortAllocator.WorkspacePortAllocationError>; registerTerminalProcesses?: (input: { readonly threadId: string; readonly terminalId: string; @@ -1127,17 +1195,28 @@ interface TerminalManagerOptions { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; + registerBrowserOpen?: ( + input: TerminalBrowserOpen.TerminalBrowserOpenOwner, + ) => Effect.Effect>; + unregisterBrowserOpen?: ( + input: TerminalBrowserOpen.TerminalBrowserOpenOwner, + ) => Effect.Effect; } export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; + const workspacePortAllocator = yield* WorkspacePortAllocator.WorkspacePortAllocator; + const browserOpen = yield* TerminalBrowserOpen.TerminalBrowserOpen; return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, + resolveWorkspaceEnvironment: workspacePortAllocator.environmentFor, registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, + registerBrowserOpen: browserOpen.register, + unregisterBrowserOpen: browserOpen.unregister, }); }); @@ -1187,6 +1266,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); + const registerBrowserOpen = + options.registerBrowserOpen ?? (() => Effect.succeed>({})); + const unregisterBrowserOpen = options.unregisterBrowserOpen ?? (() => Effect.void); yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); @@ -1732,6 +1814,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: action.threadId, terminalId: action.terminalId, }); + yield* unregisterBrowserOpen({ + threadId: action.threadId, + terminalId: action.terminalId, + }); yield* publishEvent({ type: "exited", threadId: action.threadId, @@ -1770,6 +1856,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: session.threadId, terminalId: session.terminalId, }); + yield* unregisterBrowserOpen({ + threadId: session.threadId, + terminalId: session.terminalId, + }); yield* startKillEscalation(process, session.threadId, session.terminalId); yield* evictInactiveSessionsIfNeeded(); }); @@ -1778,6 +1868,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func shellCandidates: ReadonlyArray, spawnEnv: NodeJS.ProcessEnv, session: TerminalSessionState, + command: string | null, index = 0, lastError: PtyAdapter.PtySpawnError | null = null, ): Effect.fn.Return< @@ -1792,8 +1883,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - const candidate = shellCandidates[index]; - if (!candidate) { + const baseCandidate = shellCandidates[index]; + if (!baseCandidate) { return yield* ( lastError ?? new PtyAdapter.PtySpawnError({ @@ -1802,6 +1893,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }) ); } + const candidate = command + ? shellCandidateForCommand(baseCandidate, command, platform) + : baseCandidate; const attempt = yield* Effect.result( options.ptyAdapter.spawn({ @@ -1826,7 +1920,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return yield* spawnError; } - return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); + return yield* trySpawn(shellCandidates, spawnEnv, session, command, index + 1, spawnError); }); const startSession = Effect.fn("terminal.startSession")(function* ( @@ -1868,8 +1962,30 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.andThen( Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); - const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); + const workspaceEnvironment = options.resolveWorkspaceEnvironment + ? yield* options.resolveWorkspaceEnvironment(session.worktreePath ?? session.cwd) + : {}; + const baseTerminalEnv = createTerminalSpawnEnv(baseEnv, { + ...session.runtimeEnv, + ...workspaceEnvironment, + }); + const browserOpenEnv = hasTerminalEnvKey(baseTerminalEnv, "BROWSER", platform) + ? {} + : yield* registerBrowserOpen({ + threadId: session.threadId, + terminalId: session.terminalId, + }); + const mergedTerminalEnv = { ...baseTerminalEnv, ...browserOpenEnv }; + const shimDir = browserOpenEnv[TerminalBrowserOpen.TERMINAL_BROWSER_OPEN_SHIM_DIR_ENV]; + const terminalEnv = shimDir + ? prependTerminalPath(mergedTerminalEnv, shimDir, platform) + : mergedTerminalEnv; + const spawnResult = yield* trySpawn( + shellCandidates, + terminalEnv, + session, + input.command ?? null, + ); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; @@ -1940,6 +2056,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: session.threadId, terminalId: session.terminalId, }); + yield* unregisterBrowserOpen({ + threadId: session.threadId, + terminalId: session.terminalId, + }); yield* evictInactiveSessionsIfNeeded(); @@ -2130,6 +2250,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session: TerminalSessionState, ) { cleanupProcessHandles(session); + yield* unregisterBrowserOpen({ + threadId: session.threadId, + terminalId: session.terminalId, + }); if (!session.process) return; yield* clearKillFiber(session.process); yield* runKillEscalation(session.process, session.threadId, session.terminalId); @@ -2142,8 +2266,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }).pipe(Effect.ignoreCause({ log: true })), ); - const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { + const openLocked = Effect.fn("terminal.openLocked")(function* ( + input: TerminalOpenInput | TerminalCommandOpenInput, + ) { const terminalId = input.terminalId; + const launchCommand = "command" in input ? input.command : null; yield* assertValidCwd(input.cwd); const sessionKey = toSessionKey(input.threadId, terminalId); @@ -2177,6 +2304,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func hasRunningSubprocess: false, childCommandLabel: null, runtimeEnv: normalizedRuntimeEnv(input.env), + launchCommand, }; const createdSession = session; @@ -2197,6 +2325,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols, rows, ...(input.env ? { env: input.env } : {}), + ...(launchCommand ? { command: launchCommand } : {}), }, "started", ); @@ -2209,11 +2338,13 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const targetCols = input.cols ?? liveSession.cols; const targetRows = input.rows ?? liveSession.rows; const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); + const nextLaunchCommand = launchCommand; const nextWorktreePath = input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath; const launchContextChanged = liveSession.cwd !== input.cwd || runtimeEnvChanged || + liveSession.launchCommand !== nextLaunchCommand || liveSession.worktreePath !== nextWorktreePath; if (launchContextChanged) { @@ -2221,6 +2352,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func liveSession.cwd = input.cwd; liveSession.worktreePath = nextWorktreePath; liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.launchCommand = nextLaunchCommand; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; @@ -2229,6 +2361,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); } else if (liveSession.status === "exited" || liveSession.status === "error") { liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.launchCommand = nextLaunchCommand; liveSession.worktreePath = nextWorktreePath; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; @@ -2249,6 +2382,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols: targetCols, rows: targetRows, ...(input.env ? { env: input.env } : {}), + ...(launchCommand ? { command: launchCommand } : {}), }, "started", ); @@ -2268,6 +2402,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const open: TerminalManager["Service"]["open"] = (input) => withThreadLock(input.threadId, openLocked(input)); + const openCommand: TerminalManager["Service"]["openCommand"] = (input) => + withThreadLock(input.threadId, openLocked(input)); + const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( input.threadId, @@ -2589,6 +2726,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func hasRunningSubprocess: false, childCommandLabel: null, runtimeEnv: normalizedRuntimeEnv(input.env), + launchCommand: null, }; const createdSession = session; yield* modifyManagerState((state) => { @@ -2603,6 +2741,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.cwd = input.cwd; session.worktreePath = input.worktreePath ?? null; session.runtimeEnv = normalizedRuntimeEnv(input.env); + session.launchCommand = null; } const cols = input.cols ?? session.cols; @@ -2655,6 +2794,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, + openCommand, attachStream, write, resize, diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index ee4ed971241..052d92935cb 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -53,7 +53,11 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { +/** + * Exported for callers that allow a non-zero exit to keep the output — `gh api graphql` exits + * non-zero for a partial answer it still prints — and so must classify the exit themselves. + */ +export const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); if ( diff --git a/apps/server/src/workspace/WorkspacePortAllocator.test.ts b/apps/server/src/workspace/WorkspacePortAllocator.test.ts new file mode 100644 index 00000000000..a8ac78b96e6 --- /dev/null +++ b/apps/server/src/workspace/WorkspacePortAllocator.test.ts @@ -0,0 +1,54 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as WorkspacePortAllocator from "./WorkspacePortAllocator.ts"; + +const allocatorLayer = it.layer( + WorkspacePortAllocator.layer.pipe( + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ), +); + +allocatorLayer("WorkspacePortAllocator", (it) => { + it.effect("keeps a workspace's ten-port range stable", () => + Effect.gen(function* () { + const allocator = yield* WorkspacePortAllocator.WorkspacePortAllocator; + const first = yield* allocator.getBasePort("/repo/worktrees/feature/../feature"); + const repeated = yield* allocator.getBasePort("/repo/worktrees/feature"); + const reloadedAllocator = yield* WorkspacePortAllocator.make(); + const afterReload = yield* reloadedAllocator.getBasePort("/repo/worktrees/feature"); + + assert.strictEqual(repeated, first); + assert.strictEqual(afterReload, first); + assert.isAtLeast(first, WorkspacePortAllocator.WORKSPACE_PORT_MIN); + assert.isAtMost(first, WorkspacePortAllocator.WORKSPACE_PORT_MAX); + assert.strictEqual(first % WorkspacePortAllocator.WORKSPACE_PORT_RANGE_SIZE, 0); + }), + ); + + it.effect("assigns non-overlapping ranges and exposes the first port", () => + Effect.gen(function* () { + const allocator = yield* WorkspacePortAllocator.WorkspacePortAllocator; + const ports = yield* Effect.forEach( + Array.from({ length: 64 }, (_, index) => `/repo/worktrees/workspace-${index}`), + allocator.getBasePort, + { concurrency: "unbounded" }, + ); + + assert.strictEqual(new Set(ports).size, ports.length); + for (const port of ports) { + assert.strictEqual(port % WorkspacePortAllocator.WORKSPACE_PORT_RANGE_SIZE, 0); + } + + const environment = yield* allocator.environmentFor("/repo/worktrees/workspace-0"); + assert.strictEqual( + environment[WorkspacePortAllocator.WORKSPACE_PORT_ENV_VAR], + String(ports[0]), + ); + }), + ); +}); diff --git a/apps/server/src/workspace/WorkspacePortAllocator.ts b/apps/server/src/workspace/WorkspacePortAllocator.ts new file mode 100644 index 00000000000..b681f6292f9 --- /dev/null +++ b/apps/server/src/workspace/WorkspacePortAllocator.ts @@ -0,0 +1,133 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +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 * as SqlClient from "effect/unstable/sql/SqlClient"; + +export const WORKSPACE_PORT_ENV_VAR = "T3CODE_WORKSPACE_PORT"; +export const WORKSPACE_PORT_RANGE_SIZE = 10; +export const WORKSPACE_PORT_MIN = 20_000; +export const WORKSPACE_PORT_MAX = 29_990; + +const WORKSPACE_PORT_RANGE_COUNT = + (WORKSPACE_PORT_MAX - WORKSPACE_PORT_MIN) / WORKSPACE_PORT_RANGE_SIZE + 1; + +interface WorkspacePortRow { + readonly basePort: number; +} + +class WorkspacePortRangesExhaustedError extends Schema.TaggedErrorClass()( + "WorkspacePortRangesExhaustedError", + {}, +) {} + +export class WorkspacePortAllocationError extends Schema.TaggedErrorClass()( + "WorkspacePortAllocationError", + { + workspacePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to allocate a development port range for workspace '${this.workspacePath}'.`; + } +} + +export class WorkspacePortAllocator extends Context.Service< + WorkspacePortAllocator, + { + readonly getBasePort: ( + workspacePath: string, + ) => Effect.Effect; + readonly environmentFor: ( + workspacePath: string, + ) => Effect.Effect, WorkspacePortAllocationError>; + } +>()("t3/workspace/WorkspacePortAllocator") {} + +function hashWorkspacePath(workspacePath: string): number { + let hash = 2_166_136_261; + for (let index = 0; index < workspacePath.length; index += 1) { + hash = Math.imul(hash ^ workspacePath.charCodeAt(index), 16_777_619); + } + return hash >>> 0; +} + +function preferredBasePort(workspacePath: string): number { + return ( + WORKSPACE_PORT_MIN + + (hashWorkspacePath(workspacePath) % WORKSPACE_PORT_RANGE_COUNT) * WORKSPACE_PORT_RANGE_SIZE + ); +} + +export const make = Effect.fn("WorkspacePortAllocator.make")(function* () { + const sql = yield* SqlClient.SqlClient; + const path = yield* Path.Path; + const allocationLock = yield* Semaphore.make(1); + + const allocate = Effect.fn("WorkspacePortAllocator.allocate")(function* (workspacePath: string) { + const normalizedPath = path.resolve(workspacePath); + return yield* sql.withTransaction( + Effect.gen(function* () { + const existing = yield* sql` + SELECT base_port AS "basePort" + FROM workspace_port_allocations + WHERE workspace_path = ${normalizedPath} + `; + const existingPort = existing[0]?.basePort; + if (existingPort !== undefined) return existingPort; + + const preferred = preferredBasePort(normalizedPath); + for (let offset = 0; offset < WORKSPACE_PORT_RANGE_COUNT; offset += 1) { + const candidate = + WORKSPACE_PORT_MIN + + (((preferred - WORKSPACE_PORT_MIN) / WORKSPACE_PORT_RANGE_SIZE + offset) % + WORKSPACE_PORT_RANGE_COUNT) * + WORKSPACE_PORT_RANGE_SIZE; + const inserted = yield* sql` + INSERT INTO workspace_port_allocations (workspace_path, base_port) + VALUES (${normalizedPath}, ${candidate}) + ON CONFLICT DO NOTHING + RETURNING base_port AS "basePort" + `; + const insertedPort = inserted[0]?.basePort; + if (insertedPort !== undefined) return insertedPort; + + const concurrentlyAllocated = yield* sql` + SELECT base_port AS "basePort" + FROM workspace_port_allocations + WHERE workspace_path = ${normalizedPath} + `; + const concurrentPort = concurrentlyAllocated[0]?.basePort; + if (concurrentPort !== undefined) return concurrentPort; + } + + return yield* new WorkspacePortRangesExhaustedError(); + }), + ); + }); + + const getBasePort: WorkspacePortAllocator["Service"]["getBasePort"] = (workspacePath) => + allocationLock + .withPermits(1)(allocate(workspacePath)) + .pipe( + Effect.mapError( + (cause) => + new WorkspacePortAllocationError({ + workspacePath, + cause, + }), + ), + ); + + const environmentFor: WorkspacePortAllocator["Service"]["environmentFor"] = (workspacePath) => + getBasePort(workspacePath).pipe( + Effect.map((basePort) => ({ [WORKSPACE_PORT_ENV_VAR]: String(basePort) })), + ); + + return WorkspacePortAllocator.of({ getBasePort, environmentFor }); +}); + +export const layer = Layer.effect(WorkspacePortAllocator, make()); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 56ea24a4a8b..0e0783b9ed6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -14,6 +14,7 @@ import { AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, + ComposerDraftSyncError, AuthSessionId, CommandId, type DiscoveredLocalServerList, @@ -22,6 +23,7 @@ import { type GitActionProgressEvent, type GitManagerServiceError, OrchestrationDispatchCommandError, + OrchestrationGetCommandOutputError, type OrchestrationEvent, type OrchestrationShellStreamEvent, type OrchestrationShellStreamItem, @@ -63,6 +65,8 @@ import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/uns import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; +import * as CommandOutputQuery from "./orchestration/CommandOutputQuery.ts"; +import * as ComposerDrafts from "./persistence/ComposerDrafts.ts"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -360,6 +364,7 @@ const makeWsRpcLayer = ( const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const commandOutputQuery = yield* CommandOutputQuery.CommandOutputQuery; const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; @@ -418,6 +423,7 @@ const makeWsRpcLayer = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; + const composerDrafts = yield* ComposerDrafts.ComposerDraftRepository; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -503,7 +509,7 @@ const makeWsRpcLayer = ( const appendSetupScriptActivity = (input: { readonly threadId: ThreadId; - readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly kind: "setup-script.failed"; readonly summary: string; readonly createdAt: string; readonly payload: Record; @@ -785,6 +791,16 @@ const makeWsRpcLayer = ( readonly worktreePath: string; }) => { const detail = projectSetupScriptCompatibilityDetail(input.error); + if ( + input.error._tag === "ProjectSetupScriptOperationError" && + input.error.operation === "openTerminal" + ) { + return Effect.logWarning("bootstrap turn start failed to launch setup script", { + threadId: command.threadId, + worktreePath: input.worktreePath, + detail, + }); + } return appendSetupScriptActivity({ threadId: command.threadId, kind: "setup-script.failed", @@ -807,55 +823,6 @@ const makeWsRpcLayer = ( ); }; - const recordSetupScriptStarted = (input: { - readonly requestedAt: string; - readonly worktreePath: string; - readonly scriptId: string; - readonly scriptName: string; - readonly terminalId: string; - }) => - Effect.gen(function* () { - const startedAt = yield* nowIso; - const payload = { - scriptId: input.scriptId, - scriptName: input.scriptName, - terminalId: input.terminalId, - worktreePath: input.worktreePath, - }; - yield* Effect.all([ - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.requested", - summary: "Starting setup script", - createdAt: input.requestedAt, - payload, - tone: "info", - }), - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.started", - summary: "Setup script started", - createdAt: startedAt, - payload, - tone: "info", - }), - ]).pipe( - Effect.asVoid, - Effect.catch((error) => - Effect.logWarning( - "bootstrap turn start launched setup script but failed to record setup activity", - { - threadId: command.threadId, - worktreePath: input.worktreePath, - scriptId: input.scriptId, - terminalId: input.terminalId, - detail: error.message, - }, - ), - ), - ); - }); - const runSetupProgram = () => Effect.gen(function* () { if (!bootstrap?.runSetupScript || !targetWorktreePath) { @@ -882,13 +849,7 @@ const makeWsRpcLayer = ( if (setupResult.status !== "started") { return Effect.void; } - return recordSetupScriptStarted({ - requestedAt, - worktreePath, - scriptId: setupResult.scriptId, - scriptName: setupResult.scriptName, - terminalId: setupResult.terminalId, - }); + return Effect.void; }, }), ); @@ -1040,6 +1001,32 @@ const makeWsRpcLayer = ( .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); return WsRpcGroup.of({ + [WS_METHODS.composerDraftUpdate]: (input) => + observeRpcEffect( + WS_METHODS.composerDraftUpdate, + composerDrafts + .update(input) + .pipe( + Effect.mapError( + () => + new ComposerDraftSyncError({ message: "Failed to save the composer draft." }), + ), + ), + { threadId: input.threadId }, + ), + [WS_METHODS.subscribeComposerDraft]: (input) => + observeRpcStream( + WS_METHODS.subscribeComposerDraft, + composerDrafts + .subscribe(input) + .pipe( + Stream.mapError( + () => + new ComposerDraftSyncError({ message: "Composer draft sync was interrupted." }), + ), + ), + { threadId: input.threadId }, + ), [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, @@ -1078,6 +1065,26 @@ const makeWsRpcLayer = ( ) : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); + if ( + normalizedCommand.type === "thread.turn.start" && + normalizedCommand.composerDraftRevision !== undefined + ) { + yield* composerDrafts + .update({ + threadId: normalizedCommand.threadId, + baseRevision: normalizedCommand.composerDraftRevision, + common: null, + clientMutationId: `turn:${normalizedCommand.commandId}`, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("failed to clear the sent composer draft", { + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); + } if (parkingCommand) { const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; if (shouldStopSessionAfterCommand) { @@ -1156,6 +1163,20 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getCommandOutput]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getCommandOutput, + commandOutputQuery.getCommandOutput(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetCommandOutputError({ + message: "Failed to load command output", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.getFullThreadDiff, @@ -1747,6 +1768,46 @@ const makeWsRpcLayer = ( "rpc.aggregate": "source-control", }, ), + [WS_METHODS.sourceControlGetDefaultRepository]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlGetDefaultRepository, + sourceControlRepositories.getDefaultRepository(input), + { + "rpc.aggregate": "source-control", + }, + ), + [WS_METHODS.sourceControlSetDefaultRepository]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlSetDefaultRepository, + sourceControlRepositories.setDefaultRepository(input), + { + "rpc.aggregate": "source-control", + }, + ), + [WS_METHODS.sourceControlListIssues]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlListIssues, + sourceControlRepositories.listIssues(input), + { + "rpc.aggregate": "source-control", + }, + ), + [WS_METHODS.sourceControlGetIssue]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlGetIssue, + sourceControlRepositories.getIssue(input), + { + "rpc.aggregate": "source-control", + }, + ), + [WS_METHODS.sourceControlResolveReferences]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlResolveReferences, + sourceControlRepositories.resolveReferences(input), + { + "rpc.aggregate": "source-control", + }, + ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, @@ -1850,7 +1911,10 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.assetsCreateUrl, Effect.gen(function* () { - if (input.resource._tag === "attachment") { + if ( + input.resource._tag === "attachment" || + input.resource._tag === "pull-request-file" + ) { return yield* issueAssetUrl({ resource: input.resource }); } if (input.resource._tag === "project-favicon") { @@ -2295,6 +2359,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const composerDrafts = yield* ComposerDrafts.ComposerDraftRepository; return HttpRouter.add( "GET", "/ws", @@ -2315,6 +2380,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( }).pipe( Effect.provide( makeWsRpcLayer(session, previewAutomationBroker).pipe( + Layer.provide(Layer.succeed(ComposerDrafts.ComposerDraftRepository, composerDrafts)), 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 49f1b532a53..963f4133bb7 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -74,6 +74,7 @@ const fixtures = [ makeActivity("command", "command_execution", { item: { command: ["bash", "-lc", "pnpm test"], + exitCode: 7, input: { command: "fallback input", ignored: "input bulk" }, result: { command: "fallback result", aggregatedOutput: "x".repeat(10_000) }, commandActions: [{ type: "unknown", output: "y".repeat(5_000) }], @@ -84,6 +85,7 @@ const fixtures = [ rawOutput: { content: "\n```\nfirst useful line\nsecond line", stdout: "unused stdout", + exitCode: 7, ignored: "raw bulk", }, ignored: "top-level bulk", @@ -167,13 +169,14 @@ describe("projectActivityPayload", () => { data: { item: { command: ["bash", "-lc", "pnpm test"], + exitCode: 7, input: { command: "fallback input" }, result: { command: "fallback result" }, }, command: "fallback data", toolCallId: "tool-command", kind: "execute", - rawOutput: { content: "first useful line" }, + rawOutput: { exitCode: 7 }, }, }); @@ -182,6 +185,25 @@ describe("projectActivityPayload", () => { files: [{ path: "src/new.ts" }, { path: "src/old.ts" }, { path: "src/second.ts" }], }, }); + + const openCodeCommand = makeActivity("opencode-command", "command_execution", { + state: { + status: "completed", + input: { command: "bun test" }, + output: "test output that should load on demand", + }, + }); + openCodeCommand.payload = { + ...openCodeCommand.payload, + detail: "test output that should load on demand", + }; + expect(projectActivityPayload(openCodeCommand).payload).toEqual({ + itemType: "command_execution", + title: "command_execution", + status: "completed", + requestKind: "command", + data: { item: { input: { command: "bun test" } } }, + }); }); it("slims MCP tool data to the fields the expanded row renders", () => { @@ -327,6 +349,54 @@ describe("superseded tool.updated snapshot dedup", () => { expect(projectedIds([inFlight, other, completed])).toEqual([inFlight.id, completed.id]); }); + it("keeps a sanitized historical command interaction beside its completion", () => { + const interaction: OrchestrationThreadActivity = { + id: EventId.make("interaction"), + tone: "tool", + kind: "tool.updated", + summary: "Tool updated", + payload: { + itemType: "command_execution", + data: { + itemId: "exec-1", + processId: "1234", + stdin: "\u0003", + threadId: "provider-thread-1", + turnId: "turn-a", + }, + }, + turnId: TurnId.make("turn-a"), + createdAt: "2026-07-27T00:00:00.000Z", + }; + const completed: OrchestrationThreadActivity = { + id: EventId.make("completed"), + tone: "tool", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + detail: "sleep 10", + data: { item: { id: "exec-1", command: "sleep 10" } }, + }, + turnId: TurnId.make("turn-a"), + createdAt: "2026-07-27T00:00:01.000Z", + }; + + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread([interaction, completed]), + }).thread.activities; + + expect(projected).toHaveLength(2); + expect(projected[0]).toMatchObject({ + kind: "command.interaction", + summary: "Sent Ctrl+C", + payload: { interaction: "ctrl_c", commandItemId: "exec-1" }, + }); + expect(projected[1]?.kind).toBe("tool.completed"); + }); + it("drops interleaved superseded updates even when a parallel call separates them", () => { // Deliberate divergence from the clients' adjacency-based collapse: a // superseded update separated from its completion by an interleaved diff --git a/apps/web/THIRD_PARTY_NOTICES.md b/apps/web/THIRD_PARTY_NOTICES.md index c9a675ef41c..8c309e918cf 100644 --- a/apps/web/THIRD_PARTY_NOTICES.md +++ b/apps/web/THIRD_PARTY_NOTICES.md @@ -9,3 +9,14 @@ Copyright (c) 2016 Roberto Huertas Licensed under the MIT License. The full license text is available in the upstream repository: . + +## Avanti completion sound + +The `public/avanti.mp3` completion sound is sourced from the +[`rail-announcements`](https://github.com/davwheat/rail-announcements/blob/main/audio/AWC/390/chime.mp3) +project. + +Copyright (c) 2021 David Wheatley + +Licensed under the MIT License. The full license text is available in the +upstream repository: . diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png index 3eed25ea6b7..6309ae83bd3 100644 Binary files a/apps/web/public/apple-touch-icon.png and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/avanti.mp3 b/apps/web/public/avanti.mp3 new file mode 100644 index 00000000000..fab399e7d3d Binary files /dev/null and b/apps/web/public/avanti.mp3 differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png index a3431b8c6df..f094969aec0 100644 Binary files a/apps/web/public/favicon-16x16.png and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png index 862f7629971..8fb03b8a632 100644 Binary files a/apps/web/public/favicon-32x32.png and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 750da22602e..e2081d975bc 100644 Binary files a/apps/web/public/favicon.ico and b/apps/web/public/favicon.ico differ diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index 791004b74fa..f6f878e40b0 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -3,6 +3,8 @@ import { RouterProvider } from "@tanstack/react-router"; import { describe, expect, it } from "vite-plus/test"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { ThreadVisitBaselineObserver } from "./components/ThreadVisitBaselineObserver"; +import { TurnCompletionSound } from "./components/TurnCompletionSound"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; @@ -17,10 +19,12 @@ describe("AppRoot", () => { const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(4); + expect(children).toHaveLength(6); expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); - expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); + expect(isValidElement(children[1]) && children[1].type).toBe(TurnCompletionSound); + expect(isValidElement(children[2]) && children[2].type).toBe(ThreadVisitBaselineObserver); + expect(isValidElement(children[3]) && children[3].type).toBe(PreviewAutomationHosts); + expect(isValidElement(children[4]) && children[4].type).toBe(ElectronBrowserHost); + expect(isValidElement(children[5]) && children[5].type).toBe(QuitHoldOverlay); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index 857125c9fda..5c626f5da09 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -1,6 +1,8 @@ import { RouterProvider } from "@tanstack/react-router"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { ThreadVisitBaselineObserver } from "./components/ThreadVisitBaselineObserver"; +import { TurnCompletionSound } from "./components/TurnCompletionSound"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; @@ -15,6 +17,8 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { return ( + + diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 7fc57cf0d03..be5da353e05 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -24,4 +24,6 @@ export const APP_STAGE_LABEL = export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); +export const APP_REPOSITORY = "yngatech/t3code"; export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const APP_COMMIT_HASH = import.meta.env.APP_COMMIT_HASH || "unknown"; diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 8f849a6e7b3..ea8180c005a 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -44,6 +44,7 @@ describe("clientPersistenceStorage", () => { await import("./clientPersistenceStorage"); const settings = { ...DEFAULT_CLIENT_SETTINGS, + completionSound: "none" as const, timestampFormat: "24-hour" as const, }; diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 62044a8659d..585767a786f 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -22,6 +22,7 @@ import { formatSubagentTokenCount, } from "@t3tools/client-runtime/state/subagentRuntime"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { deriveToolRowPresentation } from "@t3tools/shared/toolRowPresentation"; import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -113,6 +114,11 @@ function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { ); } +/** Same vocabulary the timeline uses, from a tool name alone. */ +function agentToolLabel(toolName: string): string { + return deriveToolRowPresentation({ toolName })?.heading ?? toolName; +} + /** * Status-dependent activity line. Live rows lead with what is happening now; * settled rows lead with the outcome. Errors are the only inline previews on @@ -124,7 +130,7 @@ function agentActivityText(agent: RuntimeSubagent): string | null { if (live) { return ( agent.progress ?? - (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? + (agent.lastToolName ? `▸ ${agentToolLabel(agent.lastToolName)}` : null) ?? agent.result ?? agent.error ); @@ -133,7 +139,7 @@ function agentActivityText(agent: RuntimeSubagent): string | null { agent.error ?? agent.result ?? agent.progress ?? - (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) + (agent.lastToolName ? `▸ ${agentToolLabel(agent.lastToolName)}` : null) ); } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a3ba7667968..d8d3d6ed1b2 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -139,6 +139,11 @@ function ProjectProjectionRetention() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); + // The responsive Sidebar swaps its desktop container for a mobile sheet at + // the breakpoint, which remounts its contents. Keep the selected project + // scope above that boundary so resizing or closing the sheet does not clear + // the user's filter. + const [sidebarProjectScopeKey, setSidebarProjectScopeKey] = useState(null); // Settings routes show the settings nav in place of whichever thread // sidebar is active. const pathname = useLocation({ select: (location) => location.pathname }); @@ -234,7 +239,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ) : legacySidebarEnabled ? ( ) : ( - + )} diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 9499ee5a691..ff6b28809bf 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,8 @@ +import type { Components } from "react-markdown"; import { describe, expect, it } from "vite-plus/test"; import { orderedListGutterStyle } from "./ChatMarkdown"; +import { createStableMarkdownComponents } from "./chatMarkdownRenderers"; describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -34,3 +36,33 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); }); }); + +describe("createStableMarkdownComponents", () => { + it("keeps renderer identities while delegating to the latest implementation", () => { + let firstRenderCount = 0; + let secondRenderCount = 0; + const firstParagraph = () => { + firstRenderCount += 1; + return

first

; + }; + const secondParagraph = () => { + secondRenderCount += 1; + return

second

; + }; + let latest: Components = { p: firstParagraph }; + const stable = createStableMarkdownComponents(() => latest); + const paragraphRenderer = stable.p; + + expect(typeof paragraphRenderer).toBe("function"); + if (typeof paragraphRenderer !== "function") return; + const renderParagraph = paragraphRenderer as (props: object) => React.ReactNode; + + renderParagraph({ children: "message" }); + latest = { p: secondParagraph }; + + expect(stable.p).toBe(paragraphRenderer); + renderParagraph({ children: "updated message" }); + expect(firstRenderCount).toBe(1); + expect(secondRenderCount).toBe(1); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ec88bc912f0..2fe5449f994 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -10,6 +10,7 @@ import { MessageSquareWarningIcon, Minimize2Icon, OctagonAlertIcon, + TerminalIcon, TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; @@ -44,6 +45,15 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "../markdown-github-alerts"; +import { remarkGithubReferences } from "../markdown-github-references"; +import { + githubReferenceHref, + MISSING_GITHUB_REFERENCE_ATTRIBUTE, + missingGithubReferenceTitle, + useGithubReferenceOpener, + useGithubReferenceResolutions, + type GithubReferenceSurface, +} from "./chat/githubReferenceLinks"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; @@ -74,6 +84,7 @@ import { } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { + buildMarkdownFileLinkParentSuffixes, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, @@ -106,6 +117,7 @@ import { openUrlInPreview, BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; +import { createStableMarkdownComponents } from "./chatMarkdownRenderers"; interface ChatMarkdownProps { text: string; @@ -117,11 +129,23 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Runs completed shell-language fences in the thread terminal. */ + onRunCodeBlock?: ((code: string) => void) | undefined; + /** A surface-specific image renderer, used when the source needs authenticated resolution. */ + imageRenderer?: Components["img"] | undefined; + /** + * The repository `#123` refers to. Without one, references stay plain text — which is what a + * number in a conversation, far likelier a step than an issue, should be. + */ + referenceContext?: GithubReferenceSurface | undefined; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; +const SHELL_CODE_BLOCK_LANGUAGES = new Set( + "bash bat batch cmd fish nu nushell powershell ps1 pwsh sh shell shellscript zsh".split(" "), +); const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; @@ -153,7 +177,6 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]); } - /** * 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+), @@ -173,19 +196,24 @@ export function orderedListGutterStyle( return { "--list-gutter": `${digits + 1}ch` }; } -const CHAT_MARKDOWN_SANITIZE_SCHEMA = { +/** + * Exported so plugins that put data attributes on the tree can prove they survive it: one missing + * from this allowlist is stripped silently, and the feature reading it stops happening. + */ +export const CHAT_MARKDOWN_SANITIZE_SCHEMA: NonNullable[0]> = { ...defaultSchema, attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], + a: [...(defaultSchema.attributes?.a ?? []), "dataGithubReference"], }, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], }, -} satisfies Parameters[0]; +}; const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, @@ -615,12 +643,14 @@ function MarkdownCodeBlock({ language, fenceTitle, theme, + onRun, children, }: { code: string; language: string; fenceTitle: string | null; theme: "light" | "dark"; + onRun?: (() => void) | undefined; children: ReactNode; }) { const [copied, setCopied] = useState(false); @@ -682,6 +712,25 @@ function MarkdownCodeBlock({ /> + {onRun ? ( + + + } + > + + + Run in terminal + + ) : null} segment.length > 0); - return segments.slice(0, -1); -} - -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 basename = pathSegments[pathSegments.length - 1]; - if (!basename) continue; - const group = groups.get(basename) ?? new Set(); - group.add(filePath); - groups.set(basename, group); - } - - const suffixByPath = new Map(); - for (const group of groups.values()) { - const uniquePaths = [...group]; - if (uniquePaths.length < 2) continue; - - const parentSegmentsByPath = new Map( - uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), - ); - const minUniqueDepthByPath = new Map(); - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; - let resolvedDepth = segments.length; - for (let depth = 1; depth <= segments.length; depth += 1) { - const candidate = segments.slice(-depth).join("/"); - const collision = uniquePaths.some((otherPath) => { - if (otherPath === filePath) return false; - const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; - return otherSegments.slice(-depth).join("/") === candidate; - }); - if (!collision) { - resolvedDepth = depth; - break; - } - } - minUniqueDepthByPath.set(filePath, resolvedDepth); - } - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; - if (segments.length === 0) continue; - const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; - const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); - suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); - } - } - - return suffixByPath; -} - const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; @@ -1360,8 +1349,25 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + onRunCodeBlock, + imageRenderer, + referenceContext, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + // Held apart so a caller spelling the context inline does not reparse the body every render. + const referenceHost = referenceContext?.host; + const referenceRepository = referenceContext?.repository; + const remarkPlugins = useMemo(() => { + const base = lineBreaks + ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS + : CHAT_MARKDOWN_REMARK_PLUGINS; + if (referenceHost === undefined || referenceRepository === undefined) return base; + // After remark-gfm, whose autolink literals are the links this rewrites as shorthand. + return [ + ...base, + [remarkGithubReferences, { host: referenceHost, repository: referenceRepository }], + ] satisfies NonNullable; + }, [lineBreaks, referenceHost, referenceRepository]); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); @@ -1406,11 +1412,11 @@ function ChatMarkdown({ return metaByText; }, [cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { - const filePaths = [ - ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), - ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), + const fileLinks = [ + ...markdownFileLinkMetaByHref.values(), + ...inlineCodeFileLinkMetaByText.values(), ]; - return buildFileLinkParentSuffixByPath(filePaths); + return buildMarkdownFileLinkParentSuffixes(fileLinks); }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); @@ -1427,6 +1433,12 @@ function ChatMarkdown({ event.clipboardData.setData("text/html", payload.html); }, []); const openChangeRequestLink = useOpenChangeRequestLink(threadRef); + const lookupReference = useGithubReferenceResolutions(referenceContext, text); + const openReference = useGithubReferenceOpener( + lookupReference, + openChangeRequestLink, + referenceContext?.threadRef, + ); const openExternalLinkInPreview = useCallback( (url: string) => { if (!threadRef) { @@ -1551,6 +1563,7 @@ function ChatMarkdown({ }; return { + ...(imageRenderer === undefined ? {} : { img: imageRenderer }), p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, @@ -1623,6 +1636,31 @@ function ChatMarkdown({ ); }, a({ node, href, children, ...props }) { + const referenceKey = node?.properties?.dataGithubReference; + if (typeof referenceKey === "string" && href) { + // A reference already links to the right place; an answer only decides where it opens + // and whether it is marked. No favicon or address tooltip: `#123` says where it goes. + const resolution = lookupReference(referenceKey); + const targetHref = githubReferenceHref(resolution, href); + return ( + { + props.onClick?.(event); + openReference(event, referenceKey, href); + }} + > + {children} + + ); + } const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { @@ -1737,12 +1775,20 @@ function ChatMarkdown({ const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); + const runCodeBlock = + !isStreaming && + onRunCodeBlock && + SHELL_CODE_BLOCK_LANGUAGES.has(language.toLowerCase()) && + codeBlock.code.trim().length > 0 + ? () => onRunCodeBlock(codeBlock.code) + : undefined; return ( {children}}> {children}}> @@ -1763,19 +1809,29 @@ function ChatMarkdown({ diffThemeName, fileLinkParentSuffixByPath, inlineCodeFileLinkMetaByText, + imageRenderer, isStreaming, markdownFileLinkMetaByHref, onTaskListChange, openFileInPanel, + onRunCodeBlock, openInPreferredEditor, openExternalLinkInPreview, openMarkdownFileInPreview, + lookupReference, + openReference, resolvedTheme, skills, text, threadRef, ]); /* eslint-enable react/no-unstable-nested-components */ + const latestMarkdownComponentsRef = useRef(markdownComponents); + latestMarkdownComponentsRef.current = markdownComponents; + const stableMarkdownComponents = useMemo( + () => createStableMarkdownComponents(() => latestMarkdownComponentsRef.current), + [], + ); return (
{text} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5bde6345c..251e8598f49 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -133,11 +133,13 @@ import { } from "../rightPanelStore"; import { isPreviewSupportedInRuntime, + readThreadPreviewState, setActivePreviewTab, useThreadPreviewState, } from "../previewStateStore"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; +import { openScriptPreview, unfinishedSetupScriptStarts } from "./preview/openScriptPreview"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; @@ -219,6 +221,11 @@ import { type ElementContextDraft, formatElementContextLabel, } from "../lib/elementContext"; +import { + appendIssueContextsToPrompt, + type IssueContextDraft, + formatIssueContextLabel, +} from "../lib/issueContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; @@ -239,6 +246,7 @@ import { threadHasOlderTurns, } from "@t3tools/client-runtime/state/threads"; import { vcsEnvironment } from "../state/vcs"; +import { markComposerDraftSent, readComposerDraftRevision } from "../state/composerDrafts"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { useProject, @@ -1285,6 +1293,14 @@ function ChatViewContent(props: ChatViewProps) { }; }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); + const queueThreadVisitBaseline = useUiStateStore((store) => store.queueThreadVisitBaseline); + const clearThreadVisitBaseline = useUiStateStore((store) => store.clearThreadVisitBaseline); + const queueAcceptedTurnVisitBaseline = useCallback( + (acceptedThreadId: ThreadId) => { + queueThreadVisitBaseline(scopedThreadKey(scopeThreadRef(environmentId, acceptedThreadId))); + }, + [environmentId, queueThreadVisitBaseline], + ); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the // settings UI never writes to remote environments), so read them from the @@ -1314,6 +1330,7 @@ function ChatViewContent(props: ChatViewProps) { const setComposerDraftElementContexts = useComposerDraftStore( (store) => store.setElementContexts, ); + const setComposerDraftIssueContexts = useComposerDraftStore((store) => store.setIssueContexts); const setComposerDraftPreviewAnnotations = useComposerDraftStore( (store) => store.setPreviewAnnotations, ); @@ -1336,6 +1353,7 @@ function ChatViewContent(props: ChatViewProps) { const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); + const composerIssueContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); @@ -1685,23 +1703,23 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? 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 - // lands later still gets its signal (markThreadVisited never moves the - // timestamp backwards). + // A viewed running turn establishes a prospective read baseline. Once + // finished, its completion time clears exactly the Done badge being viewed. + // Both timestamps are server-projected and visits never move backwards. useEffect(() => { - const completedAt = serverThread?.latestTurn?.completedAt; - if (!serverThread?.id || !completedAt) return; - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - completedAt, - ); + const latestTurn = serverThread?.latestTurn; + if (!latestTurn) return; + const visitedAt = + latestTurn.completedAt ?? + (latestTurn.state === "running" ? (latestTurn.startedAt ?? latestTurn.requestedAt) : null); + if (visitedAt) markThreadVisited(routeThreadKey, visitedAt); }, [ markThreadVisited, - serverThread?.environmentId, - serverThread?.id, + routeThreadKey, serverThread?.latestTurn?.completedAt, + serverThread?.latestTurn?.requestedAt, + serverThread?.latestTurn?.startedAt, + serverThread?.latestTurn?.state, ]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { @@ -3068,13 +3086,21 @@ function ChatViewContent(props: ChatViewProps) { data: `${script.command}\r`, }, }); - if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); + if (writeResult._tag === "Failure") { + if (!isAtomCommandInterrupted(writeResult)) { + const error = squashAtomCommandFailure(writeResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + return; } + + // The command is running now. Surface whatever it serves if the action + // asked for it; deliberately not awaited, so a slow or unreachable + // preview target never delays the terminal. + void openScriptPreview({ threadRef: activeThreadRef, script, openPreview }); }, [ activeProject, @@ -3082,6 +3108,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadId, activeThreadRef, gitCwd, + openPreview, setTerminalOpen, setThreadError, storeNewTerminal, @@ -3097,6 +3124,43 @@ function ChatViewContent(props: ChatViewProps) { ], ); + // `runOnWorktreeCreate` actions are launched by the server, not by + // `runProjectScript`, so their auto-open rides the `setup-script.started` + // activity instead. Keyed by activity id: activities are re-delivered on + // reconnect and refetch, and one started run must open one preview. + const autoOpenedSetupActivityIds = useRef>(new Set()); + useEffect(() => { + if (!activeThreadRef || !activeProject) return; + for (const start of unfinishedSetupScriptStarts(threadActivities)) { + if (autoOpenedSetupActivityIds.current.has(start.activityId)) continue; + autoOpenedSetupActivityIds.current.add(start.activityId); + const script = activeProject.scripts.find((entry) => entry.id === start.scriptId); + if (!script) continue; + // The setup run is not worth stealing a preview the user is already + // using in this thread — mark it handled and leave their tab alone. + if (readThreadPreviewState(activeThreadRef).activeTabId !== null) continue; + void openScriptPreview({ threadRef: activeThreadRef, script, openPreview }); + } + }, [activeProject, activeThreadRef, openPreview, threadActivities]); + + const runCodeBlockInTerminal = useCallback( + (code: string) => { + const command = code.replace(/[\r\n]+$/, ""); + if (!command.trim()) return; + void runProjectScript( + { + id: "code-block", + name: "command", + command, + icon: "play", + runOnWorktreeCreate: false, + }, + { rememberAsLastInvoked: false }, + ); + }, + [runProjectScript], + ); + const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -4286,6 +4350,7 @@ function ChatViewContent(props: ChatViewProps) { draft.images.length > 0 || draft.terminalContexts.length > 0 || draft.elementContexts.length > 0 || + draft.issueContexts.length > 0 || draft.previewAnnotations.length > 0 || draft.reviewComments.length > 0), ); @@ -4946,6 +5011,7 @@ function ChatViewContent(props: ChatViewProps) { images: sendContextImages, terminalContexts: composerTerminalContexts, elementContexts: composerElementContexts, + issueContexts: composerIssueContexts, previewAnnotations: sendContextPreviewAnnotations, reviewComments: composerReviewComments, selectedProvider: ctxSelectedProvider, @@ -4986,6 +5052,7 @@ function ChatViewContent(props: ChatViewProps) { terminalContexts: composerTerminalContexts, elementContextCount: composerElementContexts.length + + composerIssueContexts.length + composerPreviewAnnotations.length + composerReviewComments.length, }); @@ -5010,6 +5077,7 @@ function ChatViewContent(props: ChatViewProps) { composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && + composerIssueContexts.length === 0 && composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) @@ -5084,10 +5152,14 @@ function ChatViewContent(props: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; + const composerIssueContextsSnapshot = [...composerIssueContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; const messageTextWithContexts = appendElementContextsToPrompt( - appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), + appendTerminalContextsToPrompt( + appendIssueContextsToPrompt(promptForSend, composerIssueContextsSnapshot), + composerTerminalContextsSnapshot, + ), composerElementContextsSnapshot, ); const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( @@ -5100,6 +5172,9 @@ function ChatViewContent(props: ChatViewProps) { ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const composerDraftRevision = isServerThread + ? readComposerDraftRevision(routeThreadRef) + : undefined; const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -5168,6 +5243,7 @@ function ChatViewContent(props: ChatViewProps) { } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); + if (isServerThread) markComposerDraftSent(routeThreadRef); composerRef.current?.resetCursorState(); let firstComposerImageName: string | null = null; @@ -5185,6 +5261,8 @@ function ChatViewContent(props: ChatViewProps) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else if (composerElementContextsSnapshot.length > 0) { titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!); + } else if (composerIssueContextsSnapshot.length > 0) { + titleSeed = formatIssueContextLabel(composerIssueContextsSnapshot[0]!); } else { titleSeed = "New thread"; } @@ -5279,6 +5357,7 @@ function ChatViewContent(props: ChatViewProps) { titleSeed: title, runtimeMode, interactionMode, + ...(composerDraftRevision === undefined ? {} : { composerDraftRevision }), ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, @@ -5287,6 +5366,7 @@ function ChatViewContent(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + queueAcceptedTurnVisitBaseline(threadIdForSend); acknowledgeActiveThreadWoke(); } } @@ -5297,6 +5377,7 @@ function ChatViewContent(props: ChatViewProps) { composerImagesRef.current.length === 0 && composerTerminalContextsRef.current.length === 0 && composerElementContextsRef.current.length === 0 && + composerIssueContextsRef.current.length === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations .length ?? 0) === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments @@ -5315,10 +5396,12 @@ function ChatViewContent(props: ChatViewProps) { composerImagesRef.current = retryComposerImages; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; composerElementContextsRef.current = composerElementContextsSnapshot; + composerIssueContextsRef.current = composerIssueContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); + setComposerDraftIssueContexts(composerDraftTarget, composerIssueContextsSnapshot); setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); composerRef.current?.resetCursorState({ @@ -5645,6 +5728,9 @@ function ChatViewContent(props: ChatViewProps) { }, }); failure = startResult._tag === "Failure" ? startResult : null; + if (failure === null) { + queueAcceptedTurnVisitBaseline(threadIdForSend); + } } if (failure === null) { @@ -5678,6 +5764,7 @@ function ChatViewContent(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + queueAcceptedTurnVisitBaseline, setComposerDraftInteractionMode, setThreadError, startThreadTurn, @@ -5773,6 +5860,9 @@ function ChatViewContent(props: ChatViewProps) { }, }); failure = startResult._tag === "Failure" ? startResult : null; + if (failure === null) { + queueAcceptedTurnVisitBaseline(nextThreadId); + } } if (failure === null) { @@ -5796,6 +5886,9 @@ function ChatViewContent(props: ChatViewProps) { } if (failure !== null) { + clearThreadVisitBaseline( + scopedThreadKey(scopeThreadRef(activeThread.environmentId, nextThreadId)), + ); const cleanupResult = await deleteThread({ environmentId, input: { @@ -5838,6 +5931,8 @@ function ChatViewContent(props: ChatViewProps) { navigate, resetLocalDispatch, runtimeMode, + clearThreadVisitBaseline, + queueAcceptedTurnVisitBaseline, startThreadTurn, environmentId, composerRef, @@ -6288,6 +6383,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadEnvironmentId={activeThread.environmentId} routeThreadKey={routeThreadKey} onOpenTurnDiff={onOpenTurnDiff} + onRunCodeBlock={activeProject ? runCodeBlockInTerminal : undefined} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} @@ -6434,6 +6530,7 @@ function ChatViewContent(props: ChatViewProps) { composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} composerElementContextsRef={composerElementContextsRef} + composerIssueContextsRef={composerIssueContextsRef} onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 413ebca305f..3aeee703be8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,7 +1,11 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { + canCreateProjectInEnvironment, + getCloneDestinationQuery, + repositoryOwnerAvatarUrl, +} from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { @@ -20,6 +24,7 @@ import { type EnvironmentId, type FilesystemBrowseResult, type ProjectId, + type SourceControlCloneDefaultRepository, type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, @@ -29,10 +34,13 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowLeftIcon, + CircleDotIcon, CornerLeftUpIcon, + ExternalLinkIcon, FileSearchIcon, FolderIcon, FolderPlusIcon, + GitPullRequestIcon, LinkIcon, MessageSquareIcon, PaletteIcon, @@ -65,8 +73,11 @@ import { filesystemEnvironment } from "../state/filesystem"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; +import { vcsEnvironment } from "../state/vcs"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { normalizeIssueContextSelection } from "../lib/issueContext"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { useThreadSearch } from "../state/queries"; @@ -148,9 +159,45 @@ import { buildSidebarProjectSnapshots, } from "../sidebarProjectGrouping"; import type { Project } from "../types"; +import { useFocusPullRequestTab, useViewPullRequest } from "../lib/viewPullRequest"; +import { getSourceControlPresentation } from "../sourceControlPresentation"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +/** + * Shows who owns a repository. The avatar is derived from the repository URL, so + * a host that does not serve one (or an offline client) falls back to the + * provider icon rather than an empty box. + */ +function RepositoryOwnerAvatar({ + fallback, + nameWithOwner, + repositoryUrl, +}: { + fallback: ReactNode; + nameWithOwner: string; + repositoryUrl: string; +}) { + const [hasFailed, setHasFailed] = useState(false); + const avatarUrl = repositoryOwnerAvatarUrl({ nameWithOwner, repositoryUrl }); + if (avatarUrl === null || hasFailed) { + return fallback; + } + + return ( + { + setHasFailed(true); + }} + /> + ); +} + function projectFavicon(project: Project) { return ( = [ @@ -282,6 +340,7 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { if (!flow) return null; if (flow.step === "confirm") return null; + if (flow.step === "default") return "Choose the default repository"; if (flow.source === "url") { return "Enter Git clone URL"; } @@ -361,6 +420,30 @@ function buildAddProjectRemoteSourceReadiness( return readiness; } +/** + * The issue picker only speaks GitHub today. When `gh` is missing or logged + * out, the list request fails with a generic provider error, so we read the + * discovery snapshot to explain what to actually do about it. + */ +function describeGitHubIssuesUnavailable( + discovery: SourceControlDiscoveryResult | null, +): string | null { + const provider = discovery?.sourceControlProviders.find((entry) => entry.kind === "github"); + if (!provider) { + return null; + } + if (provider.status !== "available") { + return `GitHub CLI is not installed. ${provider.installHint}`; + } + if (provider.auth.status === "unauthenticated") { + return ( + Option.getOrNull(provider.auth.detail) ?? + "GitHub CLI is not authenticated. Run `gh auth login` and retry." + ); + } + return null; +} + function errorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; @@ -569,6 +652,15 @@ function OpenCommandPaletteDialog(props: { const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); + const listSourceControlIssues = useAtomQueryRunner(sourceControlEnvironment.issues, { + reportFailure: false, + reportDefect: false, + }); + const loadSourceControlIssue = useAtomQueryRunner(sourceControlEnvironment.issue, { + reportFailure: false, + reportDefect: false, + }); + const addComposerDraftIssueContext = useComposerDraftStore((store) => store.addIssueContext); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); @@ -603,6 +695,7 @@ function OpenCommandPaletteDialog(props: { return map; }, [environments, primaryEnvironmentId, providers]); const [viewStack, setViewStack] = useState([]); + const [issuePickerEmptyMessage, setIssuePickerEmptyMessage] = useState(null); const currentView = viewStack.at(-1) ?? null; const environmentIds = useMemo( () => @@ -806,13 +899,26 @@ function OpenCommandPaletteDialog(props: { browseEnvironment?.serverConfig?.environment.platform.os, ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; - const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; + const isRemoteProjectDefaultStep = addProjectCloneFlow?.step === "default"; + const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; + // The repository and default-repository steps type into a list, not a path. + const isRemoteProjectPathStep = + addProjectCloneFlow === null || addProjectCloneFlow.step === "confirm"; const browsePath = useMemo( - () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), - [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + () => + getFilesystemBrowsePath( + query, + browseEnvironmentPlatform, + isRemoteProjectPathStep, + isCloneDestinationStep, + ), + [browseEnvironmentPlatform, isCloneDestinationStep, isRemoteProjectPathStep, query], ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; + // The clone step types the folder to create, so the listing keeps showing the + // parent directory and every navigation carries the name along. + const cloneDestinationName = browsePath.destinationName; const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { @@ -850,6 +956,31 @@ function OpenCommandPaletteDialog(props: { const currentProjectCwd = currentProjectId ? (projectCwdById.get(currentProjectId) ?? null) : null; + const activeThreadRef = activeThread + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : null; + const activeThreadGitCwd = activeThread?.worktreePath ?? currentProjectCwd; + const activeThreadGitStatus = useEnvironmentQuery( + activeThreadRef && activeThreadGitCwd + ? vcsEnvironment.status({ + environmentId: activeThreadRef.environmentId, + input: { cwd: activeThreadGitCwd }, + }) + : null, + ); + const { canViewPullRequest, viewPullRequest } = useViewPullRequest( + activeThreadGitStatus.data, + activeThreadRef, + ); + const { canFocusPullRequestTab, focusPullRequestTab } = useFocusPullRequestTab( + activeThreadGitStatus.data, + activeThreadRef, + ); + const changeRequestTerminology = getSourceControlPresentation( + activeThreadGitStatus.data?.sourceControlProvider, + ).terminology; + const isPullRequestStatusLoading = + activeThreadGitStatus.isPending && activeThreadGitStatus.data === null; const currentProjectCwdForBrowse = browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId ? currentProjectCwd @@ -882,6 +1013,7 @@ function OpenCommandPaletteDialog(props: { () => filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery), [browseEntries, browsePath.filterQuery], ); + const cloneDestinationExists = browseEntries.some((entry) => entry.name === cloneDestinationName); const prefetchBrowsePath = useCallback( async ( @@ -1110,6 +1242,7 @@ function OpenCommandPaletteDialog(props: { function popView(): void { browseNavigation.invalidate(); setAddProjectCloneFlow(null); + setIssuePickerEmptyMessage(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); } @@ -1307,6 +1440,137 @@ function OpenCommandPaletteDialog(props: { ], ); + const contextualProjectCwd = contextualProjectRef + ? (projectCwdById.get(contextualProjectRef.projectId) ?? null) + : null; + const contextualSourceControlDiscovery = useEnvironmentQuery( + contextualProjectRef === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: contextualProjectRef.environmentId, + input: {}, + }), + ); + + /** + * Fetch the full issue (body + comments), open a fresh draft for the + * contextual project, and attach the issue as a pending context block. The + * user writes and sends the prompt themselves — nothing is auto-sent. + */ + const attachIssueToNewThread = useCallback( + async (issueNumber: number): Promise => { + if (contextualProjectRef === null || contextualProjectCwd === null) { + return; + } + const issueResult = await loadSourceControlIssue({ + environmentId: contextualProjectRef.environmentId, + input: { cwd: contextualProjectCwd, number: issueNumber }, + }); + if (issueResult._tag === "Failure") { + if (!isAtomCommandInterrupted(issueResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not load issue", + description: errorMessage(squashAtomCommandFailure(issueResult)), + }), + ); + } + return; + } + const selection = normalizeIssueContextSelection(issueResult.value); + if (selection === null) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not load issue", + description: "The issue payload was missing a number or title.", + }), + ); + return; + } + const opened = await handleNewThread(contextualProjectRef); + if (opened === null) { + return; + } + addComposerDraftIssueContext(opened.draftId, selection); + }, + [ + addComposerDraftIssueContext, + contextualProjectCwd, + contextualProjectRef, + handleNewThread, + loadSourceControlIssue, + ], + ); + + const startNewThreadFromIssueBrowse = useCallback(async (): Promise => { + if (contextualProjectRef === null || contextualProjectCwd === null) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "No project selected", + description: "Open or add a project before starting a thread from an issue.", + }), + ); + return; + } + const issuesResult = await listSourceControlIssues({ + environmentId: contextualProjectRef.environmentId, + input: { cwd: contextualProjectCwd }, + }); + if (issuesResult._tag === "Failure") { + if (isAtomCommandInterrupted(issuesResult)) { + return; + } + setIssuePickerEmptyMessage( + describeGitHubIssuesUnavailable(contextualSourceControlDiscovery.data ?? null) ?? + errorMessage(squashAtomCommandFailure(issuesResult)), + ); + pushPaletteView({ addonIcon: , groups: [] }); + return; + } + const issues = issuesResult.value.issues; + setIssuePickerEmptyMessage(issues.length === 0 ? "No open issues." : null); + pushPaletteView({ + addonIcon: , + groups: + issues.length === 0 + ? [] + : [ + { + value: "issues", + label: "Open issues", + items: issues.map((issue) => ({ + kind: "action" as const, + value: `issue:${issue.number}`, + searchTerms: [ + `#${issue.number}`, + String(issue.number), + issue.title, + ...issue.labels, + ], + title: issue.title, + titleLeadingContent: ( + #{issue.number} + ), + icon: , + run: async () => { + await attachIssueToNewThread(issue.number); + }, + })), + }, + ], + }); + }, [ + attachIssueToNewThread, + contextualProjectCwd, + contextualProjectRef, + contextualSourceControlDiscovery.data, + listSourceControlIssues, + pushPaletteView, + ]); + const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( (option) => ({ kind: "action", @@ -1445,6 +1709,16 @@ function OpenCommandPaletteDialog(props: { }); } + actionItems.push({ + kind: "action", + value: "action:new-thread-from-issue", + searchTerms: ["new thread from github issue", "issue", "github", "gh", "ticket", "bug"], + title: "New thread from GitHub issue…", + icon: , + keepOpen: true, + run: startNewThreadFromIssueBrowse, + }); + actionItems.push({ kind: "submenu", value: "action:new-thread-in", @@ -1482,6 +1756,55 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:focus-pull-request-tab", + searchTerms: [ + "view pull request", + "focus pr tab", + "view merge request", + "open pr", + "open mr", + "change request", + "source control", + ], + title: `View ${changeRequestTerminology.shortLabel}`, + description: canFocusPullRequestTab + ? undefined + : isPullRequestStatusLoading + ? `Checking ${changeRequestTerminology.singular} status…` + : `No open ${changeRequestTerminology.singular} for this thread`, + disabled: !canFocusPullRequestTab, + icon: , + shortcutCommand: "sourceControl.focusPullRequestTab", + run: focusPullRequestTab, + }); + + actionItems.push({ + kind: "action", + value: "action:view-pull-request", + searchTerms: [ + "open pr in browser", + "open pull request in browser", + "open merge request in browser", + "open mr", + "change request", + "source control", + "github", + "gitlab", + ], + title: `Open ${changeRequestTerminology.shortLabel} in browser`, + description: canViewPullRequest + ? undefined + : isPullRequestStatusLoading + ? `Checking ${changeRequestTerminology.singular} status…` + : `No open ${changeRequestTerminology.singular} for this thread`, + disabled: !canViewPullRequest, + icon: , + shortcutCommand: "sourceControl.viewPullRequest", + run: viewPullRequest, + }); + actionItems.push({ kind: "action", value: "action:add-project", @@ -1770,6 +2093,33 @@ function OpenCommandPaletteDialog(props: { return getAddProjectInitialQueryForEnvironment(environmentId); } + /** Leaves the fork's default-repository step for the destination step. */ + function chooseCloneDefaultRepository( + defaultRepository: SourceControlCloneDefaultRepository, + ): void { + if (addProjectCloneFlow?.step !== "default") { + return; + } + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: addProjectCloneFlow.repositoryInput, + repository: addProjectCloneFlow.repository, + remoteUrl: addProjectCloneFlow.remoteUrl, + defaultRepository, + }); + setHighlightedItemValue(null); + setQuery( + getCloneDestinationQuery({ + parentPath: getDefaultCloneParentPath(addProjectCloneFlow.environmentId), + nameWithOwner: addProjectCloneFlow.repository.nameWithOwner, + remoteUrl: addProjectCloneFlow.remoteUrl, + }), + ); + setBrowseGeneration((generation) => generation + 1); + } + async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { if (!addProjectCloneFlow) { return; @@ -1793,7 +2143,10 @@ function OpenCommandPaletteDialog(props: { const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); if (!provider) { - const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + const destinationPath = getCloneDestinationQuery({ + parentPath: getDefaultCloneParentPath(addProjectCloneFlow.environmentId), + remoteUrl: rawRepository, + }); setAddProjectCloneFlow({ step: "confirm", environmentId: addProjectCloneFlow.environmentId, @@ -1830,7 +2183,26 @@ function OpenCommandPaletteDialog(props: { return; } const repository = lookupResult.value; - const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + if (repository.parentNameWithOwner) { + setAddProjectCloneFlow({ + step: "default", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: rawRepository, + repository, + parentNameWithOwner: repository.parentNameWithOwner, + remoteUrl: repository.sshUrl, + }); + setHighlightedItemValue(null); + setQuery(""); + setBrowseGeneration((generation) => generation + 1); + return; + } + const destinationPath = getCloneDestinationQuery({ + parentPath: getDefaultCloneParentPath(addProjectCloneFlow.environmentId), + nameWithOwner: repository.nameWithOwner, + remoteUrl: repository.sshUrl, + }); setAddProjectCloneFlow({ step: "confirm", environmentId: addProjectCloneFlow.environmentId, @@ -1845,6 +2217,11 @@ function OpenCommandPaletteDialog(props: { return; } + // The default-repository step advances by picking a list item, not by submit. + if (addProjectCloneFlow.step !== "confirm") { + return; + } + const rawDestination = (destinationPathInput ?? query).trim(); if (rawDestination.length === 0 || isRemoteProjectCloning) { return; @@ -1880,10 +2257,28 @@ function OpenCommandPaletteDialog(props: { return; } + // A fork is the only clone that needs its repository named on the server. + const forkRepository = + addProjectCloneFlow.repository?.parentNameWithOwner === undefined + ? null + : addProjectCloneFlow.repository; + setIsRemoteProjectCloning(true); const cloneResult = await cloneRepository({ environmentId: addProjectCloneFlow.environmentId, input: { + // Only a fork needs naming: it is what lets the server wire up the + // upstream remote. Every other clone stays a plain URL clone, with no + // second repository lookup on the server. + ...(forkRepository + ? { + provider: forkRepository.provider, + repository: forkRepository.nameWithOwner, + ...(addProjectCloneFlow.defaultRepository + ? { defaultRepository: addProjectCloneFlow.defaultRepository } + : {}), + } + : {}), remoteUrl: addProjectCloneFlow.remoteUrl, destinationPath, }, @@ -1901,12 +2296,23 @@ function OpenCommandPaletteDialog(props: { } return; } + // The clone itself succeeded, so this is a warning rather than a failure: + // the repository is on disk, just without the remote that was asked for. + if (forkRepository && !cloneResult.value.upstream) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Upstream remote not added", + description: `Cloned, but ${forkRepository.parentNameWithOwner} could not be wired up as a remote.`, + }), + ); + } await handleAddProject(cloneResult.value.cwd); } const browseTo = useCallback( async (name: string): Promise => { - const nextQuery = appendBrowsePathSegment(query, name); + const nextQuery = `${appendBrowsePathSegment(query, name)}${cloneDestinationName}`; await browseNavigation.run( () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), () => { @@ -1916,7 +2322,7 @@ function OpenCommandPaletteDialog(props: { }, ); }, - [browseNavigation, prefetchBrowsePath, query], + [browseNavigation, cloneDestinationName, prefetchBrowsePath, query], ); const browseUp = useCallback(async (): Promise => { @@ -1929,11 +2335,11 @@ function OpenCommandPaletteDialog(props: { () => prefetchBrowsePath(parentPath), () => { setHighlightedItemValue(null); - setQuery(parentPath); + setQuery(`${parentPath}${cloneDestinationName}`); setBrowseGeneration((generation) => generation + 1); }, ); - }, [browseNavigation, browsePath.parentPath, prefetchBrowsePath]); + }, [browseNavigation, browsePath.parentPath, cloneDestinationName, prefetchBrowsePath]); // Resolve the add-project path from browse data when available. When the // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the @@ -1963,20 +2369,84 @@ function OpenCommandPaletteDialog(props: { ); const remoteProjectContext = useMemo(() => { - if (addProjectCloneFlow?.step !== "confirm") { + if (addProjectCloneFlow?.step !== "confirm" && addProjectCloneFlow?.step !== "default") { return null; } + const flow = addProjectCloneFlow; + const parentNameWithOwner = + flow.step === "default" ? flow.parentNameWithOwner : flow.repository?.parentNameWithOwner; return { - title: addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput, - description: addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl, - icon: remoteProjectSourceIcon(addProjectCloneFlow.source, ITEM_ICON_CLASS), + title: flow.repository?.nameWithOwner ?? flow.repositoryInput, + // A fork keeps the same second line across both steps. The clone URL only + // stands in where the title is not already the repository's full name. + description: parentNameWithOwner + ? `forked from ${parentNameWithOwner}` + : (flow.repository?.url ?? flow.remoteUrl), + icon: remoteProjectSourceIcon(flow.source, ITEM_ICON_CLASS), }; }, [addProjectCloneFlow]); + /** + * The fork step, modelled on `gh repo set-default`: pick which repository + * pull requests, issues, and releases should target once both remotes exist. + * The fork leads: it is the repository the user asked to clone, and it keeps + * the pin agreeing with the remote a branch on the fork tracks. + */ + const cloneDefaultRepositoryGroups = useMemo((): CommandPaletteView["groups"] => { + if (addProjectCloneFlow?.step !== "default") { + return []; + } + + const flow = addProjectCloneFlow; + const options: ReadonlyArray<{ + readonly choice: SourceControlCloneDefaultRepository; + readonly nameWithOwner: string; + readonly remoteName: string; + }> = [ + { choice: "cloned", nameWithOwner: flow.repository.nameWithOwner, remoteName: "origin" }, + { choice: "parent", nameWithOwner: flow.parentNameWithOwner, remoteName: "upstream" }, + ]; + + return [ + { + value: "clone-default-repository", + // Reads on its own: the palette title sits above the repository card, too + // far away to be read as one sentence with this. + label: "Where pull requests, issues, and releases go", + items: options.map((option) => ({ + kind: "action" as const, + value: `action:clone-default-repository:${option.choice}`, + searchTerms: [option.nameWithOwner, option.choice, option.remoteName], + title: option.nameWithOwner, + // The remote name says which repository this is without a sentence. + titleTrailingContent: ( + + {option.remoteName} + + ), + icon: ( + + ), + keepOpen: true, + run: async () => { + chooseCloneDefaultRepository(option.choice); + }, + })), + }, + ]; + // `chooseCloneDefaultRepository` reads the same flow state this memo keys on. + }, [addProjectCloneFlow]); + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; if (addProjectCloneFlow?.step === "repository") { displayedGroups = []; + } else if (addProjectCloneFlow?.step === "default") { + displayedGroups = cloneDefaultRepositoryGroups; } else if (addProjectCloneFlow?.step === "confirm") { displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; } else if (isBrowsing) { @@ -1992,15 +2462,22 @@ function OpenCommandPaletteDialog(props: { isBrowsing && !relativePathNeedsActiveProject && canCreateProjectInEnvironment(browseEnvironment?.connection.phase); + // A destination name is checked against the listing it will be created in; + // otherwise the query itself is the target directory. + const submitPathAlreadyExists = + cloneDestinationName.length > 0 + ? cloneDestinationExists + : hasTrailingPathSeparator(query) + ? Boolean(browseResult) + : exactBrowseEntry !== null; const willCreateProjectPath = canSubmitBrowsePath && !isBrowsePending && query.trim().length > 0 && !hasHighlightedBrowseItem && - (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); + !submitPathAlreadyExists; const useMetaForMod = isMacPlatform(navigator.platform); const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; - const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; const submitActionLabel = isCloneDestinationStep ? willCreateProjectPath ? "Create & Clone" @@ -2341,7 +2818,9 @@ function OpenCommandPaletteDialog(props: { ); diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 3ec748c6bcb..72c29e0af94 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -73,6 +73,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { }} highlighterOptions={{ theme: diffThemeName, + preferredHighlighter: "shiki-wasm", tokenizeMaxLineLength: 1_000, useTokenTransformer: true, }} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 7b824370b39..feceb317d39 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -90,7 +90,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; -import { openPullRequestLink } from "~/lib/openPullRequestLink"; +import { useViewPullRequest } from "~/lib/viewPullRequest"; interface GitActionsControlProps { gitCwd: string | null; @@ -1108,6 +1108,10 @@ export default function GitActionsControl({ const isRepo = gitStatus?.isRepo ?? true; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; + const { viewPullRequest: viewPullRequestLink } = useViewPullRequest( + gitStatusForActions, + activeThreadRef, + ); const allFiles = gitStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); @@ -1220,7 +1224,7 @@ export default function GitActionsControl({ }; }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); - const openExistingPr = useCallback(async () => { + const viewPullRequest = useCallback(async () => { const openPr = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr : null; // Beside the thread where it was made, the way the browser opens beside it. Checked before // the shell, which opening in the app does not need. @@ -1228,36 +1232,8 @@ export default function GitActionsControl({ onOpenPullRequest(openPr.number); return; } - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - data: threadToastData, - }); - return; - } - const prUrl = openPr?.url ?? null; - if (!prUrl) { - toastManager.add({ - type: "error", - title: "No open pull request found.", - data: threadToastData, - }); - return; - } - void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => { - console.error(err); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open pull request link", - description: err instanceof Error ? err.message : "An error occurred.", - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), - ); - }); - }, [gitStatusForActions, onOpenPullRequest, threadToastData]); + await viewPullRequestLink(); + }, [gitStatusForActions, onOpenPullRequest, viewPullRequestLink]); runGitActionWithToast = useEffectEvent( async ({ @@ -1541,7 +1517,7 @@ export default function GitActionsControl({ const runQuickAction = () => { if (quickAction.kind === "open_pr") { - void openExistingPr(); + void viewPullRequest(); return; } if (quickAction.kind === "open_publish") { @@ -1605,7 +1581,7 @@ export default function GitActionsControl({ const openDialogForMenuItem = (item: GitActionMenuItem) => { if (item.disabled) return; if (item.kind === "open_pr") { - void openExistingPr(); + void viewPullRequest(); return; } if (item.dialogAction === "push") { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a7a5b638c0e..f9f86ddadbb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -94,6 +94,7 @@ import { buildSidebarProjectSnapshots, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; +import { useComposerThreadHasDraftContent } from "../composerDraftStore"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; @@ -750,6 +751,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; + const hasDraft = useComposerThreadHasDraftContent(threadRef); const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -1127,6 +1129,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + const draftIcon = hasDraft ? ( + + + + ) : null; if (variant === "slim") { return ( @@ -1168,6 +1190,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> {title} + {draftIcon} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1463,6 +1486,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} + {draftIcon} {terminalStatusIcon} {prBadge} {diff ? ( @@ -1612,7 +1636,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { ); }); -export default function Sidebar() { +type SidebarProps = { + projectScopeKey: string | null; + onProjectScopeKeyChange: (projectScopeKey: string | null) => void; +}; + +export default function Sidebar({ projectScopeKey, onProjectScopeKeyChange }: SidebarProps) { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); @@ -1848,7 +1877,6 @@ export default function Sidebar() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. - const [projectScopeKey, setProjectScopeKey] = useState(null); const scopedProjectGroup = useMemo( () => projectScopeKey === null @@ -1869,9 +1897,9 @@ export default function Sidebar() { ); useEffect(() => { if (projectScopeKey !== null && scopedProjectGroup === null) { - setProjectScopeKey(null); + onProjectScopeKeyChange(null); } - }, [projectScopeKey, scopedProjectGroup]); + }, [onProjectScopeKeyChange, projectScopeKey, scopedProjectGroup]); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content // subscription. Selecting a number keeps typing in a draft composer from @@ -3419,7 +3447,7 @@ export default function Sidebar() { - setProjectScopeKey(value === "all" ? null : (value as string)) + onProjectScopeKeyChange(value === "all" ? null : (value as string)) } > onActiveTerminalChange(terminalId)} + onMouseDown={(event) => { + if (event.button === 1) event.preventDefault(); + }} + onAuxClick={(event) => { + if (event.button !== 1) return; + event.preventDefault(); + event.stopPropagation(); + onCloseTerminal(terminalId); + }} > diff --git a/apps/web/src/components/ThreadVisitBaselineObserver.tsx b/apps/web/src/components/ThreadVisitBaselineObserver.tsx new file mode 100644 index 00000000000..7b9e3210165 --- /dev/null +++ b/apps/web/src/components/ThreadVisitBaselineObserver.tsx @@ -0,0 +1,27 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useEffect } from "react"; + +import { useThreadShells } from "../state/entities"; +import { useUiStateStore } from "../uiStateStore"; + +export function ThreadVisitBaselineObserver() { + const threadShells = useThreadShells(); + const pendingThreadVisitBaselineKeys = useUiStateStore( + (state) => state.pendingThreadVisitBaselineKeys, + ); + const resolveThreadVisitBaseline = useUiStateStore((state) => state.resolveThreadVisitBaseline); + + useEffect(() => { + if (pendingThreadVisitBaselineKeys.length === 0) return; + const pendingKeys = new Set(pendingThreadVisitBaselineKeys); + for (const thread of threadShells) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const requestedAt = thread.latestTurn?.requestedAt; + if (pendingKeys.has(threadKey) && requestedAt) { + resolveThreadVisitBaseline(threadKey, requestedAt); + } + } + }, [pendingThreadVisitBaselineKeys, resolveThreadVisitBaseline, threadShells]); + + return null; +} diff --git a/apps/web/src/components/TurnCompletionSound.tsx b/apps/web/src/components/TurnCompletionSound.tsx new file mode 100644 index 00000000000..93062212f78 --- /dev/null +++ b/apps/web/src/components/TurnCompletionSound.tsx @@ -0,0 +1,65 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useMemo, useRef } from "react"; + +import { useClientSettings } from "../hooks/useSettings"; +import { useEnvironmentShellStatuses, useThreadShells } from "../state/entities"; +import { playCompletionSound } from "../lib/completionSound"; +import { + reconcileCompletionSoundSnapshots, + type CompletionSoundThreadSnapshot, +} from "../lib/completionSound.logic"; + +export function TurnCompletionSound() { + const threadShells = useThreadShells(); + const environmentShellStatuses = useEnvironmentShellStatuses(); + const completionSound = useClientSettings((settings) => settings.completionSound); + const pendingInputSoundReadyEnvironmentIdsRef = useRef(new Set()); + const snapshotsByThreadKey = useMemo(() => { + const next = new Map(); + for (const thread of threadShells) { + next.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), { + turnId: thread.latestTurn?.turnId ?? null, + state: thread.latestTurn?.state ?? null, + sessionStatus: thread.session?.status ?? null, + hasPendingUserInput: thread.hasPendingUserInput, + pendingInputSoundReady: pendingInputSoundReadyEnvironmentIdsRef.current.has( + thread.environmentId, + ), + }); + } + return next; + }, [threadShells]); + const previousSnapshotsByThreadKeyRef = useRef | null>(null); + + useEffect(() => { + const previousSnapshotsByThreadKey = previousSnapshotsByThreadKeyRef.current; + if (previousSnapshotsByThreadKey !== null) { + const notifiableThreadKeys = reconcileCompletionSoundSnapshots( + previousSnapshotsByThreadKey, + snapshotsByThreadKey, + ); + if (notifiableThreadKeys.length > 0) { + playCompletionSound(completionSound); + } + } + previousSnapshotsByThreadKeyRef.current = snapshotsByThreadKey; + + const knownEnvironmentIds = new Set(environmentShellStatuses.keys()); + for (const environmentId of pendingInputSoundReadyEnvironmentIdsRef.current) { + if (!knownEnvironmentIds.has(environmentId)) { + pendingInputSoundReadyEnvironmentIdsRef.current.delete(environmentId); + } + } + for (const [environmentId, status] of environmentShellStatuses) { + if (status === "live") { + pendingInputSoundReadyEnvironmentIdsRef.current.add(environmentId); + } + } + }, [completionSound, environmentShellStatuses, snapshotsByThreadKey]); + + return null; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5072c5870a7..90adf55b57a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -76,7 +76,9 @@ import { } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { type ElementContextDraft } from "../../lib/elementContext"; +import { type IssueContextDraft } from "../../lib/issueContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; +import { ComposerPendingIssueContexts } from "./ComposerPendingIssueContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; import { @@ -106,6 +108,7 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; +import { useServerComposerDraftSync } from "../../state/composerDrafts"; type ComposerCommandMenuPosition = { bottom: number; @@ -475,6 +478,7 @@ export interface ChatComposerHandle { images: ComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; elementContexts: ElementContextDraft[]; + issueContexts: IssueContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; selectedPromptEffort: string | null; @@ -564,6 +568,7 @@ export interface ChatComposerProps { composerImagesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; composerElementContextsRef: React.RefObject; + composerIssueContextsRef: React.RefObject; composerRef: React.RefObject; // Callbacks @@ -649,6 +654,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerImagesRef, composerTerminalContextsRef, composerElementContextsRef, + composerIssueContextsRef, onSend, onInterrupt, onImplementPlanInNewThread, @@ -673,10 +679,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ const composerDraft = useComposerThreadDraft(composerDraftTarget); + useServerComposerDraftSync(routeKind === "server" ? routeThreadRef : null); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; const composerElementContexts = composerDraft.elementContexts; + const composerIssueContexts = composerDraft.issueContexts; const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; @@ -697,6 +705,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const removeComposerDraftElementContext = useComposerDraftStore( (store) => store.removeElementContext, ); + const removeComposerDraftIssueContext = useComposerDraftStore( + (store) => store.removeIssueContext, + ); const removeComposerDraftPreviewAnnotation = useComposerDraftStore( (store) => store.removePreviewAnnotation, ); @@ -1318,6 +1329,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerElementContextsRef.current = composerElementContexts; }, [composerElementContexts, composerElementContextsRef]); + useEffect(() => { + composerIssueContextsRef.current = composerIssueContexts; + }, [composerIssueContexts, composerIssueContextsRef]); + // ------------------------------------------------------------------ // Composer menu highlight sync // ------------------------------------------------------------------ @@ -2573,6 +2588,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) images: composerImagesRef.current, terminalContexts: composerTerminalContextsRef.current, elementContexts: composerElementContextsRef.current, + issueContexts: composerIssueContextsRef.current, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, selectedPromptEffort, @@ -2595,6 +2611,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerImagesRef, composerTerminalContextsRef, composerElementContextsRef, + composerIssueContextsRef, composerPreviewAnnotations, composerReviewComments, focusComposer, @@ -2903,6 +2920,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> )} + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerIssueContexts.length > 0 && ( + + removeComposerDraftIssueContext(composerDraftTarget, contextId) + } + className="mb-3" + /> + )} + {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index dd966bf5795..f8a1d51579c 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { deriveToolRowPresentation } from "@t3tools/shared/toolRowPresentation"; import { type PendingApproval } from "../../session-logic"; interface ComposerPendingApprovalPanelProps { @@ -10,8 +11,17 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova approval, pendingCount, }: ComposerPendingApprovalPanelProps) { - const approvalSummary = - approval.requestKind === "command" + // The tool's own name is more honest than the three request buckets, which + // route anything that isn't a command or a read to "file change". + const presentation = approval.toolName + ? deriveToolRowPresentation({ + toolName: approval.toolName, + input: approval.toolInput, + }) + : undefined; + const approvalSummary = presentation + ? `${presentation.heading} — approval requested` + : approval.requestKind === "command" ? "Command approval requested" : approval.requestKind === "file-read" ? "File-read approval requested" @@ -21,7 +31,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova ? "Command" : approval.requestKind === "file-read" ? "File to read" - : "File change"; + : presentation + ? presentation.heading + : "File change"; return (
diff --git a/apps/web/src/components/chat/ComposerPendingIssueContexts.tsx b/apps/web/src/components/chat/ComposerPendingIssueContexts.tsx new file mode 100644 index 00000000000..b0d24da4595 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingIssueContexts.tsx @@ -0,0 +1,87 @@ +import { CircleDot, X } from "lucide-react"; + +import { + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "../composerInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; +import { type IssueContextDraft, formatIssueContextLabel } from "~/lib/issueContext"; + +interface ComposerPendingIssueContextsProps { + contexts: ReadonlyArray; + onRemove: (contextId: string) => void; + className?: string; +} + +interface ComposerPendingIssueContextChipProps { + context: IssueContextDraft; + onRemove: (contextId: string) => void; +} + +function buildTooltipContent(context: IssueContextDraft): string { + const lines: string[] = []; + lines.push(formatIssueContextLabel(context)); + if (context.repository) lines.push(context.repository); + if (context.author) lines.push(`opened by ${context.author}`); + if (context.comments.length > 0) { + lines.push(`${context.comments.length} comment${context.comments.length === 1 ? "" : "s"}`); + } + const body = context.body.trim(); + if (body.length > 0) { + lines.push(""); + lines.push(body.slice(0, 600)); + } + return lines.join("\n"); +} + +export function ComposerPendingIssueContextChip({ + context, + onRemove, +}: ComposerPendingIssueContextChipProps) { + const label = formatIssueContextLabel(context); + return ( + + + + {label} + + + } + /> + + {buildTooltipContent(context)} + + + ); +} + +export function ComposerPendingIssueContexts({ + contexts, + onRemove, + className, +}: ComposerPendingIssueContextsProps) { + if (contexts.length === 0) return null; + return ( +
+ {contexts.map((context) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 194edc0bd5b..c13bb25f185 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,9 +1,11 @@ import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; +import { MessagesTimeline } from "./MessagesTimeline"; + vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; @@ -125,17 +127,13 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); -function matchMedia() { - return { - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - }; -} - -let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; - -beforeAll(async () => { +// The unit project runs on the node environment, so the module graph needs DOM +// globals in place before it is evaluated. Stubbing from `vi.hoisted` runs +// ahead of the imports above, which lets MessagesTimeline be imported +// statically. Importing it inside a `beforeAll` instead put the module graph's +// transform cost under a hook timeout, and on a loaded machine that timeout +// tripped and skipped all 18 tests in this file without failing it. +vi.hoisted(() => { const classList = { add: () => {}, remove: () => {}, @@ -150,7 +148,11 @@ beforeAll(async () => { clear: () => {}, }); vi.stubGlobal("window", { - matchMedia, + matchMedia: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }), addEventListener: () => {}, removeEventListener: () => {}, requestAnimationFrame: (callback: FrameRequestCallback) => { @@ -166,9 +168,7 @@ beforeAll(async () => { offsetHeight: 0, }, }); - - ({ MessagesTimeline } = await import("./MessagesTimeline")); -}, 30_000); +}); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; @@ -225,7 +225,41 @@ function buildUserTimelineEntry(text: string) { }; } +function renderAssistantMessage(text: string, streaming = false) { + const entry = buildUserTimelineEntry(text); + return renderToStaticMarkup( + {}} + timelineEntries={[ + { + ...entry, + message: { + ...entry.message, + role: "assistant" as const, + turnId: TurnId.make("turn-assistant"), + streaming, + }, + }, + ]} + />, + ); +} + describe("MessagesTimeline", () => { + it("only offers completed shell fences as terminal commands", () => { + const shellMarkup = renderAssistantMessage("```sh\nbun test\n```"); + + expect(shellMarkup).toContain('aria-label="Run in terminal"'); + expect(shellMarkup).toContain("lucide-terminal"); + expect(renderAssistantMessage("```ts\nconst answer = 42;\n```")).not.toContain( + 'aria-label="Run in terminal"', + ); + expect(renderAssistantMessage("```bash\nbun test\n```", true)).not.toContain( + 'aria-label="Run in terminal"', + ); + }, 20_000); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; @@ -554,6 +588,30 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); + it("keeps the completed state in setup lifecycle labels", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Setup script completed"); + }); + it("formats changed file paths from the workspace root", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); + expect(markup).toContain("apps/web/src/session-logic.ts"); + expect(markup).not.toContain("t3code/apps/web/src/session-logic.ts"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); + it("drops the generated worktree name from Edit and Write tool paths", () => { + const worktreeRoot = "/Users/cameron/.t3/worktrees/t3code-d9980d37"; + const editedPath = "apps/web/src/dictation/dictationSession.ts"; + const writtenPath = "apps/web/src/dictation/dictationSession.test.ts"; + const renderTool = (toolName: "Edit" | "Write", relativePath: string) => + renderToStaticMarkup( + , + ); + + const editedMarkup = renderTool("Edit", editedPath); + const writtenMarkup = renderTool("Write", writtenPath); + expect(editedMarkup).toContain("Edited file"); + expect(editedMarkup).toContain(editedPath); + expect(editedMarkup).not.toContain(`t3code-d9980d37/${editedPath}`); + expect(writtenMarkup).toContain("Wrote file"); + expect(writtenMarkup).toContain(writtenPath); + expect(writtenMarkup).not.toContain(`t3code-d9980d37/${writtenPath}`); + }); + + it("shows structured file-change previews instead of serialized tool input", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("tools/main.swift"); + expect(markup).not.toContain("workspace/tools/main.swift"); + expect(markup).toContain("2 additions, 1 deletions"); + expect(markup).not.toContain("file_path"); + }); + it("renders review comment contexts as structured cards instead of raw tags", () => { const markup = renderToStaticMarkup( { createdAt: "2026-03-17T19:12:28.000Z", label: "Glob", tone: "tool", + itemType: "command_execution", toolLifecycleStatus: "failed", detail: "No files found", + exitCode: 17, }, }, ]} @@ -680,6 +811,36 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain("lucide-x"); - expect(markup).toContain('aria-label="Tool call failed"'); + expect(markup).toContain('aria-label="Exit code 17"'); + expect(markup).toContain("Exit code 17"); + }); + + it("renders a zero exit code for successful commands", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("lucide-check"); + expect(markup).toContain('aria-label="Exit code 0"'); + expect(markup).toContain("Exit code 0"); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f5c529ff315..5cc3662764d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,6 +1,8 @@ import { type EnvironmentId, + EventId, type MessageId, + type OrchestrationGetCommandOutputResult, type ScopedThreadRef, type ServerProviderSkill, type TurnId, @@ -15,6 +17,10 @@ import { const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); const NOOP_OPEN_AGENTS = () => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; +import { + deriveToolRowPresentation, + type ToolRowArgument, +} from "@t3tools/shared/toolRowPresentation"; import { createContext, Fragment, @@ -55,6 +61,7 @@ import { GlobeIcon, HammerIcon, MessageCircleIcon, + CircleDotIcon, MousePointerClickIcon, PaintbrushIcon, MinusIcon, @@ -69,6 +76,7 @@ import { Button } from "../ui/button"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; +import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; import { MessageCopyButton } from "./MessageCopyButton"; import { @@ -99,6 +107,7 @@ import { extractTrailingElementContexts, type ParsedElementContextEntry, } from "~/lib/elementContext"; +import { type ParsedIssueContextEntry } from "~/lib/issueContext"; import { extractTrailingPreviewAnnotation, type ParsedPreviewAnnotation, @@ -106,6 +115,7 @@ import { import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { useCommandOutput } from "~/state/queries"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { @@ -141,6 +151,7 @@ interface TimelineRowSharedState { onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onRunCodeBlock?: ((code: string) => void) | undefined; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; agentPanelModel: AgentPanelModel; @@ -216,6 +227,7 @@ interface MessagesTimelineProps { turnDiffSummaryByAssistantMessageId: Map; routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onRunCodeBlock?: ((code: string) => void) | undefined; revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; @@ -262,6 +274,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, + onRunCodeBlock, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, @@ -514,6 +527,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onImageExpand, onOpenTurnDiff, + onRunCodeBlock, onToggleTurnFold, onToggleWorkGroup, agentPanelModel, @@ -530,6 +544,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onImageExpand, onOpenTurnDiff, + onRunCodeBlock, onToggleTurnFold, onToggleWorkGroup, agentPanelModel, @@ -971,6 +986,7 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; @@ -1018,6 +1034,16 @@ function UserTimelineRow({ row }: { row: Extract ))} + {issueContexts.length > 0 ? ( +
+ {issueContexts.map((context) => ( + + ))} +
+ ) : null} {elementContexts.length > 0 ? (
{elementContexts.map((context) => ( @@ -1116,6 +1142,7 @@ function AssistantTimelineRow({ row }: { row: Extract + + + {props.context.header} + + } + /> + + {tooltipText} + + + ); +}); + function UserMessagePreviewAnnotationCard(props: { annotation: ParsedPreviewAnnotation; image: NonNullable[number] | null; @@ -2006,16 +2056,59 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { }; } +/** + * One vocabulary for every provider, derived at render time. See + * `toolRowPresentation` for why this must not be written back onto the entry. + */ +function toolRowPresentationFor(workEntry: TimelineWorkEntry) { + if (workEntry.sourceActivityKind?.startsWith("setup-script.") || workEntry.agentSpawn) { + return undefined; + } + return deriveToolRowPresentation({ + toolName: workEntry.toolName, + itemType: workEntry.itemType, + label: workEntry.toolTitle ?? workEntry.label, + detail: workEntry.detail, + input: workEntry.toolInput, + command: workEntry.command, + changedFiles: workEntry.changedFiles, + }); +} + +function formatToolRowArgument( + argument: ToolRowArgument, + workspaceRoot: string | undefined, +): string { + if (argument.kind !== "path") { + return argument.value; + } + const displayPath = formatToolFilePath(argument.value, workspaceRoot); + return argument.moreCount ? `${displayPath} +${argument.moreCount} more` : displayPath; +} + +/** Tool rows already sit inside a project-scoped thread, so its root label is redundant. */ +function formatToolFilePath(path: string, workspaceRoot: string | undefined): string { + return formatWorkspaceRelativePath(path, workspaceRoot, { includeWorkspaceLabel: false }); +} + function workEntryPreview( - workEntry: Pick, + workEntry: Pick, workspaceRoot: string | undefined, ) { if (workEntry.command) return workEntry.command; + if (workEntry.itemType === "file_change" && (workEntry.changedFiles?.length ?? 0) > 0) { + const [firstPath] = workEntry.changedFiles ?? []; + if (!firstPath) return null; + const displayPath = formatToolFilePath(firstPath, workspaceRoot); + return workEntry.changedFiles!.length === 1 + ? displayPath + : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; + } if (workEntry.detail) return workEntry.detail; if ((workEntry.changedFiles?.length ?? 0) === 0) return null; const [firstPath] = workEntry.changedFiles ?? []; if (!firstPath) return null; - const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); + const displayPath = formatToolFilePath(firstPath, workspaceRoot); return workEntry.changedFiles!.length === 1 ? displayPath : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; @@ -2045,20 +2138,156 @@ function buildToolCallExpandedBody( } else if (workEntry.command?.trim()) { blocks.push(workEntry.command.trim()); } - if (workEntry.detail?.trim()) { + const toolArguments = buildToolArgumentLines(workEntry, workspaceRoot); + if (toolArguments) { + blocks.push(toolArguments); + } + // The adapters serialize a tool's whole input into `detail`, so once the + // arguments are shown as fields the JSON restates them unreadably. + if (workEntry.detail?.trim() && !(toolArguments && detailIsSerializedInput(workEntry))) { blocks.push(workEntry.detail.trim()); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { blocks.push( - changedFiles - .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) - .join("\n"), + changedFiles.map((filePath) => formatToolFilePath(filePath, workspaceRoot)).join("\n"), ); } return blocks.length > 0 ? blocks.join("\n\n") : null; } +/** `Edit: {"file_path":…}` — the adapters' serialized-input detail format. */ +function detailIsSerializedInput( + workEntry: Pick, +): boolean { + const detail = workEntry.detail?.trim(); + return ( + detail !== undefined && + workEntry.toolName !== undefined && + detail.startsWith(`${workEntry.toolName}: {`) + ); +} + +const TOOL_ARGUMENT_LABELS: Readonly> = { + file_path: "File", + notebook_path: "Notebook", + path: "Path", + pattern: "Pattern", + query: "Query", + skill: "Skill", + args: "Arguments", + to: "To", + subject: "Subject", + url: "URL", + offset: "From line", + limit: "Lines", +}; + +/** Tool arguments as readable fields rather than one serialized blob. */ +function buildToolArgumentLines( + workEntry: TimelineWorkEntry, + workspaceRoot: string | undefined, +): string | null { + const input = workEntry.toolInput; + if (!input) { + return null; + } + const lines: string[] = []; + for (const [key, value] of Object.entries(input)) { + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + continue; + } + const label = TOOL_ARGUMENT_LABELS[key] ?? key; + const rendered = + typeof value === "string" && + (key === "file_path" || key === "notebook_path" || key === "path") + ? formatToolFilePath(value, workspaceRoot) + : value.toString(); + lines.push(`${label}: ${rendered}`); + } + return lines.length > 0 ? lines.join("\n") : null; +} + +function buildCommandOutputBody(result: OrchestrationGetCommandOutputResult): string { + if (result.status === "unavailable") { + return "Command output is unavailable."; + } + + const blocks: string[] = []; + if (result.output) { + blocks.push(result.output.trimEnd()); + } else if (result.stdout && result.stderr) { + blocks.push(`stdout\n${result.stdout.trimEnd()}`); + blocks.push(`stderr\n${result.stderr.trimEnd()}`); + } else if (result.stdout) { + blocks.push(result.stdout.trimEnd()); + } else if (result.stderr) { + blocks.push(`stderr\n${result.stderr.trimEnd()}`); + } + if (result.exitCode !== null && result.exitCode !== 0) { + blocks.push(`Process exited with code ${result.exitCode}`); + } + return blocks.length > 0 ? blocks.join("\n\n") : "No output"; +} + +const CommandOutputExpandedBody = memo(function CommandOutputExpandedBody(props: { + workEntry: TimelineWorkEntry; +}) { + const { workEntry } = props; + const { activeThreadEnvironmentId, threadRef } = use(TimelineRowCtx); + const pending = workEntry.toolLifecycleStatus === "inProgress"; + const query = useCommandOutput({ + environmentId: pending ? null : activeThreadEnvironmentId, + threadId: threadRef?.threadId ?? null, + activityId: EventId.make(workEntry.id), + }); + const command = (workEntryRawCommand(workEntry) ?? workEntry.command)?.trim(); + const commandBlock = command ? ( +
+      {command}
+    
+ ) : null; + + if (pending) { + return ( + <> + {commandBlock} +
+          Output will be available when the command finishes.
+        
+ + ); + } + + if (query.error) { + return ( + <> + {commandBlock} +
+ Couldn’t load command output. + +
+ + ); + } + + const body = query.data + ? buildCommandOutputBody(query.data) + : query.isPending + ? "Loading output…" + : "Command output is unavailable."; + return ( + <> + {commandBlock} +
+        {body}
+      
+ + ); +}); + function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || @@ -2070,14 +2299,36 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if (workEntry.requestKind === "file-read") return "eye"; if (workEntry.requestKind === "file-change") return "square-pen"; + // The tool's own name beats itemType where the adapters' substring + // classification gets it wrong (TaskCreate reads as a file write). + switch (workEntry.toolName) { + case "Read": + return "eye"; + case "Grep": + case "Glob": + case "ToolSearch": + return "wrench"; + case "SendMessage": + return "message-circle"; + case "TaskCreate": + case "TaskUpdate": + case "TaskList": + return "check"; + case "WebFetch": + case "WebSearch": + return "globe"; + } + if (workEntry.itemType === "command_execution" || workEntry.command) { return "terminal"; } + // Before the file-change test: viewing an image discovers a path, which + // otherwise wins and stamps a read-only row with an edit pencil. + if (workEntry.itemType === "image_view") return "eye"; if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { return "square-pen"; } if (workEntry.itemType === "web_search") return "globe"; - if (workEntry.itemType === "image_view") return "eye"; switch (workEntry.itemType) { case "mcp_tool_call": @@ -2105,6 +2356,13 @@ function capitalizePhrase(value: string): string { } function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { + if (workEntry.sourceActivityKind?.startsWith("setup-script.")) { + return capitalizePhrase(workEntry.label); + } + const presentation = toolRowPresentationFor(workEntry); + if (presentation) { + return presentation.heading; + } if (!workEntry.toolTitle) { return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); } @@ -2230,17 +2488,36 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); const heading = toolWorkEntryHeading(workEntry); - const rawPreview = workEntryPreview(workEntry, workspaceRoot); + // A presentation owns its argument, including deciding there isn't one — + // falling back here would reinstate the serialized-input detail it dropped. + const presentation = toolRowPresentationFor(workEntry); + const rawPreview = presentation + ? presentation.argument + ? formatToolRowArgument(presentation.argument, workspaceRoot) + : null + : workEntryPreview(workEntry, workspaceRoot); const preview = rawPreview && normalizeCompactToolLabel(rawPreview).toLowerCase() === normalizeCompactToolLabel(heading).toLowerCase() ? null : rawPreview; - const displayText = preview ? `${heading} - ${preview}` : heading; + const fileChangeStat = + workEntry.fileChangeStat && hasNonZeroStat(workEntry.fileChangeStat) + ? workEntry.fileChangeStat + : null; + const displayText = [ + preview ? `${heading} - ${preview}` : heading, + fileChangeStat ? `+${fileChangeStat.additions} -${fileChangeStat.deletions}` : null, + ] + .filter(Boolean) + .join(" "); const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); - const canExpand = expandedBody !== null; + const isCommandExecution = workEntry.itemType === "command_execution"; + const canExpand = isCommandExecution || expandedBody !== null; const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); + const exitCodeLabel = + workEntry.exitCode === undefined ? null : `Exit code ${workEntry.exitCode.toString()}`; const showDestructiveRowStyle = showFailedIndicator && (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); @@ -2302,6 +2579,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { {preview && ( {preview} )} + {fileChangeStat ? ( + + ) : null}

@@ -2326,18 +2611,23 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { render={ } > - Failed + {exitCodeLabel ?? "Failed"} ) : showSuccessIndicator ? ( } + render={ + + } > - Completed + {exitCodeLabel ?? "Completed"} ) : showNeutralIndicator ? ( @@ -2363,15 +2653,19 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
- {expanded && canExpand && expandedBody ? ( + {expanded && canExpand ? (
-
-            {expandedBody}
-          
+ {isCommandExecution ? ( + + ) : expandedBody ? ( +
+              {expandedBody}
+            
+ ) : null}
) : null} diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index afe35e18520..03e6c36db4b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -14,10 +14,10 @@ import { useRemoteOpenState, } from "../../remoteOpen"; import { useEnvironment } from "../../state/environments"; -import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { ChevronDownIcon, CopyIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "../ui/menu"; import { AntigravityIcon, CursorIcon, @@ -46,6 +46,8 @@ import { import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { toastManager } from "../ui/toast"; type OpenInOption = { label: string; @@ -224,6 +226,19 @@ export const OpenInPicker = memo(function OpenInPicker({ [effectiveEditors], ); const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ + target: "path", + onCopy: ({ path }) => { + toastManager.add({ type: "success", title: "Path copied", description: path }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error.message, + }); + }, + }); const openInEditor = useCallback( (editorId: EditorId | null) => { @@ -346,6 +361,16 @@ export const OpenInPicker = memo(function OpenInPicker({ )} )} + + { + if (openInCwd) copyPathToClipboard(openInCwd, { path: openInCwd }); + }} + > + diff --git a/apps/web/src/components/chat/githubReferenceLinks.test.ts b/apps/web/src/components/chat/githubReferenceLinks.test.ts new file mode 100644 index 00000000000..dca6fee9819 --- /dev/null +++ b/apps/web/src/components/chat/githubReferenceLinks.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + githubReferenceHref, + missingGithubReferenceTitle, + type GithubReferenceResolution, +} from "./githubReferenceLinks"; + +const WRITTEN = "https://github.com/pingdotgg/t3code/issues/6039"; + +function resolved(url: string | null): GithubReferenceResolution { + return { + status: "resolved", + reference: { + repository: "pingdotgg/t3code", + number: 6039, + kind: "pull-request", + title: "A fix", + state: "open", + url, + }, + }; +} + +/** + * Where a reference is followed to is the whole of what resolving changes, and the pull request + * case is the one that decides whether it opens here or in a browser: only the host's own address + * carries `/pull/`, which is what the page recognises. + */ +describe("githubReferenceHref", () => { + it("follows the address the host gave, which is what makes a pull request recognisable", () => { + const href = "https://github.com/pingdotgg/t3code/pull/6039"; + + expect(githubReferenceHref(resolved(href), WRITTEN)).toBe(href); + }); + + it("keeps the link as written whenever the host said nothing usable", () => { + for (const resolution of [ + { status: "unresolved" }, + { status: "missing" }, + resolved(null), + ] satisfies ReadonlyArray) { + expect(githubReferenceHref(resolution, WRITTEN)).toBe(WRITTEN); + } + }); +}); + +describe("missingGithubReferenceTitle", () => { + it("explains only the reference the host said is not there", () => { + expect(missingGithubReferenceTitle({ status: "missing" }, "#6039")).toBe( + "Could not find #6039 — it may be private or deleted.", + ); + expect(missingGithubReferenceTitle({ status: "unresolved" }, "#6039")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/chat/githubReferenceLinks.ts b/apps/web/src/components/chat/githubReferenceLinks.ts new file mode 100644 index 00000000000..c223d3adb40 --- /dev/null +++ b/apps/web/src/components/chat/githubReferenceLinks.ts @@ -0,0 +1,142 @@ +/** + * What the `#123` in a rendered body points at, and where following it goes. Resolving only + * improves a link that already works, and only an answer marks one as broken: a request that + * failed on the way leaves every reference alone. + */ +import type { + EnvironmentId, + ScopedThreadRef, + SourceControlResolvedReference, +} from "@t3tools/contracts"; +import { createContext, useCallback, useMemo } from "react"; + +import { + collectGithubReferences, + formatGithubReferenceKey, + type GithubReferenceContext, +} from "~/markdown-github-references"; +import { sourceControlEnvironment } from "~/state/sourceControl"; +import { useEnvironmentQuery } from "~/state/query"; + +/** The context a body needs to have its references both written and answered. */ +export interface GithubReferenceSurface extends GithubReferenceContext { + readonly environmentId: EnvironmentId; + /** The checkout the question is asked from, which names the host it is asked of. */ + readonly cwd: string; + /** The thread this body is read beside, if any: a reference opens as a tab next to it. */ + readonly threadRef?: ScopedThreadRef | undefined; +} + +/** The thread a pull request surface is mounted beside, told to the bodies it renders. */ +export const GithubReferenceThreadContext = createContext(undefined); + +export type GithubReferenceResolution = + /** No answer: on its way, never asked for, or asked and not given. Nothing is claimed. */ + | { readonly status: "unresolved" } + /** The host has nothing under this number: deleted, never there, or someone else's. */ + | { readonly status: "missing" } + | { readonly status: "resolved"; readonly reference: SourceControlResolvedReference }; + +const UNRESOLVED: GithubReferenceResolution = { status: "unresolved" }; + +/** Reads what is known about one reference, by the key the anchor carries. */ +export type GithubReferenceLookup = (key: string) => GithubReferenceResolution; + +export function useGithubReferenceResolutions( + surface: GithubReferenceSurface | undefined, + text: string, +): GithubReferenceLookup { + const host = surface?.host; + const repository = surface?.repository; + const references = useMemo( + () => + host === undefined || repository === undefined + ? [] + : collectGithubReferences({ host, repository }, text), + [host, repository, text], + ); + + const query = useEnvironmentQuery( + surface && references.length > 0 + ? sourceControlEnvironment.references({ + environmentId: surface.environmentId, + input: { cwd: surface.cwd, references }, + }) + : null, + ); + + const answered = query.data; + const byKey = useMemo(() => { + const resolutions = new Map(); + // A reference is keyed by repository and number, which an Enterprise install and github.com + // spell identically — so answers from another host are not answers about these references. + if (answered === null || answered.host.toLowerCase() !== host?.toLowerCase()) + return resolutions; + for (const reference of answered.references) { + resolutions.set( + formatGithubReferenceKey(reference), + reference.kind === null ? { status: "missing" } : { status: "resolved", reference }, + ); + } + return resolutions; + }, [answered, host]); + + return useCallback((key) => byKey.get(key) ?? UNRESOLVED, [byKey]); +} + +/** + * Where to follow a reference: the link as written until the host gives its own address, which is + * what makes a pull request recognisable as one on the way out. + */ +export function githubReferenceHref( + resolution: GithubReferenceResolution, + writtenHref: string, +): string { + return resolution.status === "resolved" && resolution.reference.url !== null + ? resolution.reference.url + : writtenHref; +} + +/** + * A reference the host has nothing under, marked the way an unknown word is, and still a link — + * the reader may have access in a browser they are signed into differently. An attribute rather + * than a class, because `.chat-markdown a` sets `text-decoration: none` and outranks one. + */ +export const MISSING_GITHUB_REFERENCE_ATTRIBUTE = "data-github-reference-missing"; + +export function missingGithubReferenceTitle( + resolution: GithubReferenceResolution, + label: string, +): string | undefined { + return resolution.status === "missing" + ? `Could not find ${label} — it may be private or deleted.` + : undefined; +} + +interface ReferenceClickEvent { + preventDefault: () => void; + stopPropagation: () => void; + readonly metaKey: boolean; + readonly ctrlKey: boolean; +} + +/** Clicked before its answer arrives, a reference follows the link as written: the right page, in + * a browser. Holding the click to open it as a tab was not worth the pending click. */ +export function useGithubReferenceOpener( + lookup: GithubReferenceLookup, + openChangeRequestLink: ( + event: ReferenceClickEvent, + targetUrl: string, + targetThreadRef?: ScopedThreadRef, + ) => boolean, + threadRef: ScopedThreadRef | undefined, +): (event: ReferenceClickEvent, key: string, writtenHref: string) => void { + return useCallback( + (event, key, writtenHref) => { + // A modifier means the browser, which the anchor's own default action already does. + if (event.metaKey || event.ctrlKey) return; + openChangeRequestLink(event, githubReferenceHref(lookup(key), writtenHref), threadRef); + }, + [lookup, openChangeRequestLink, threadRef], + ); +} diff --git a/apps/web/src/components/chatMarkdownRenderers.ts b/apps/web/src/components/chatMarkdownRenderers.ts new file mode 100644 index 00000000000..e1833059b83 --- /dev/null +++ b/apps/web/src/components/chatMarkdownRenderers.ts @@ -0,0 +1,32 @@ +import React, { type ReactNode } from "react"; +import type { Components } from "react-markdown"; + +const STABLE_RENDERER_KEYS = [ + "p", + "blockquote", + "li", + "input", + "a", + "code", + "table", + "details", + "pre", +] as const; + +/** Keep element types stable so ordinary UI updates do not remount selected text. */ +export function createStableMarkdownComponents(readLatest: () => Components): Components { + const stable = Object.fromEntries( + STABLE_RENDERER_KEYS.map((key) => [ + key, + (props: object) => { + const renderer = readLatest()[key]; + return typeof renderer === "function" + ? (renderer as (rendererProps: object) => ReactNode)(props) + : React.createElement(key, props); + }, + ]), + ) as Components; + + stable.img = (props) => React.createElement(readLatest().img ?? "img", props); + return stable; +} diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 8d24b34a433..f0aed8c2854 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -10,6 +10,7 @@ import { getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, + resolveDesktopUpdateButtonTone, shouldShowArm64IntelBuildWarning, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, @@ -108,6 +109,49 @@ describe("desktop update button state", () => { }); }); +describe("resolveDesktopUpdateButtonTone", () => { + it("stays quiet while the update downloads in the background", () => { + expect( + resolveDesktopUpdateButtonTone({ + ...baseState, + status: "downloading", + availableVersion: "1.1.0", + downloadPercent: 42.5, + }), + ).toBe("quiet"); + }); + + it("calls for action once the update is downloaded", () => { + expect( + resolveDesktopUpdateButtonTone({ + ...baseState, + status: "downloaded", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }), + ).toBe("cta"); + }); + + it("calls for action when a failed download can be retried", () => { + expect( + resolveDesktopUpdateButtonTone({ + ...baseState, + status: "available", + availableVersion: "1.1.0", + message: "network unavailable", + errorContext: "download", + canRetry: true, + }), + ).toBe("cta"); + }); + + it("stays idle when there is no update to act on", () => { + expect(resolveDesktopUpdateButtonTone(baseState)).toBe("idle"); + expect(resolveDesktopUpdateButtonTone({ ...baseState, status: "checking" })).toBe("idle"); + expect(resolveDesktopUpdateButtonTone(null)).toBe("idle"); + }); +}); + describe("getDesktopUpdateActionError", () => { it("returns user-visible message for accepted failed attempts", () => { const result: DesktopUpdateActionResult = { diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index dc09d7ca877..b98e52de58b 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -48,6 +48,24 @@ export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): return resolveDesktopUpdateButtonAction(state) !== "none"; } +export type DesktopUpdateButtonTone = "cta" | "quiet" | "idle"; + +/** + * The background download needs no input, so it stays quiet. Only a state that + * wants a click — install, or retry a failed download — gets call-to-action colour. + */ +export function resolveDesktopUpdateButtonTone( + state: DesktopUpdateState | null, +): DesktopUpdateButtonTone { + if (state && resolveDesktopUpdateButtonAction(state) !== "none") { + return "cta"; + } + if (state?.status === "downloading") { + return "quiet"; + } + return "idle"; +} + export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean { return state?.hostArch === "arm64" && state.appArch === "x64"; } diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index f0d989a7d5b..e146a864303 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -109,6 +109,7 @@ export function AnnotatableCodeView({ }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); + const setReviewComments = useComposerDraftStore((store) => store.setReviewComments); const reviewComments = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, ); @@ -121,6 +122,7 @@ export function AnnotatableCodeView({ annotation: DiffCommentLineAnnotation; } | null>(null); const [draftText, setDraftText] = useState(""); + const [editDraft, setEditDraft] = useState<{ id: string; text: string } | null>(null); const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]); const items = useMemo[]>( @@ -165,10 +167,13 @@ export function AnnotatableCodeView({ }), [draft, files, reviewComments, sectionId], ); + const activeEditDraft = + editDraft && reviewComments.some((comment) => comment.id === editDraft.id) ? editDraft : null; const removeEntry = useCallback( (entryId: string) => { setSelectedLines(null); + if (editDraft?.id === entryId) setEditDraft(null); if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) { setDraft(null); setDraftText(""); @@ -176,7 +181,7 @@ export function AnnotatableCodeView({ removeReviewComment(composerDraftTarget, entryId); } }, - [composerDraftTarget, draft, removeReviewComment], + [composerDraftTarget, draft, editDraft?.id, removeReviewComment], ); const submitEntry = useCallback( @@ -203,6 +208,16 @@ export function AnnotatableCodeView({ [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle], ); + const editEntry = useCallback( + (entryId: string, text: string) => { + setReviewComments( + composerDraftTarget, + reviewComments.map((comment) => (comment.id === entryId ? { ...comment, text } : comment)), + ); + }, + [composerDraftTarget, reviewComments, setReviewComments], + ); + const beginComment = useCallback( (range: SelectedLineRange | null, context: DiffSelectionContext) => { if (!range) return; @@ -236,7 +251,7 @@ export function AnnotatableCodeView({ [filesByKey, sectionId, sectionTitle], ); - const hasOpenComment = draft !== null; + const hasOpenComment = draft !== null || activeEditDraft !== null; return ( key={codeViewKey} @@ -271,6 +286,23 @@ export function AnnotatableCodeView({ onTextChange={setDraftText} onCancel={() => removeEntry(entry.id)} onComment={(text) => submitEntry(entry.id, text)} + edit={{ + active: activeEditDraft?.id === entry.id, + text: activeEditDraft?.id === entry.id ? activeEditDraft.text : entry.text, + onStart: () => { + setSelectedLines(null); + setEditDraft({ id: entry.id, text: entry.text }); + }, + onChange: (text) => + setEditDraft((current) => + current?.id === entry.id ? { ...current, text } : current, + ), + onCancel: () => setEditDraft(null), + onSave: (text) => { + editEntry(entry.id, text); + setEditDraft(null); + }, + }} onDelete={() => removeEntry(entry.id)} /> ))} diff --git a/apps/web/src/components/diffs/DiffCommentAnnotation.test.tsx b/apps/web/src/components/diffs/DiffCommentAnnotation.test.tsx index 2c53c9059dc..468e5650346 100644 --- a/apps/web/src/components/diffs/DiffCommentAnnotation.test.tsx +++ b/apps/web/src/components/diffs/DiffCommentAnnotation.test.tsx @@ -7,6 +7,14 @@ const callbacks = { onTextChange: vi.fn(), onCancel: vi.fn(), onComment: vi.fn(), + edit: { + active: false, + text: "", + onStart: vi.fn(), + onChange: vi.fn(), + onCancel: vi.fn(), + onSave: vi.fn(), + }, onDelete: vi.fn(), }; @@ -71,6 +79,7 @@ describe("DiffCommentAnnotation", () => { expect(markup).not.toContain("chat-composer-glass"); expect(markup).not.toContain("on +78"); expect(markup).toContain("Please keep this branch explicit."); + expect(markup).toContain('aria-label="Edit comment"'); expect(markup).toContain('aria-label="Delete comment"'); expect(markup).toContain("border-s-2"); expect(markup).toContain("bg-primary/[0.045]"); @@ -89,4 +98,20 @@ describe("DiffCommentAnnotation", () => { expect(markup).toContain("Keep this unsaved draft"); }); + + it("renders a controlled saved-comment edit", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Revised comment"); + expect(markup).toContain("⌘/Ctrl Enter to save"); + expect(markup).toContain(">Save"); + }); }); diff --git a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx index d210732b6b6..4e560b8c2d8 100644 --- a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx +++ b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx @@ -1,4 +1,4 @@ -import { MessageCircle, Trash2 } from "lucide-react"; +import { MessageCircle, Pencil, Trash2 } from "lucide-react"; import { useState, type ReactNode } from "react"; import { Button } from "~/components/ui/button"; @@ -13,6 +13,15 @@ interface DiffCommentSecondaryAction { readonly onAction: (text: string) => void; } +interface DiffCommentEditState { + readonly active: boolean; + readonly text: string; + readonly onStart: () => void; + readonly onChange: (text: string) => void; + readonly onCancel: () => void; + readonly onSave: (text: string) => void; +} + interface DiffCommentAnnotationProps { kind: "draft" | "comment"; rangeLabel: string; @@ -20,6 +29,7 @@ interface DiffCommentAnnotationProps { onTextChange?: (text: string) => void; onCancel: () => void; onComment: (text: string) => void; + edit?: DiffCommentEditState; onDelete?: () => void; placeholder?: string; submitLabel?: string; @@ -35,6 +45,7 @@ export function DiffCommentAnnotation({ onTextChange, onCancel, onComment, + edit, onDelete, placeholder = "Add a comment…", submitLabel = "Comment", @@ -42,10 +53,22 @@ export function DiffCommentAnnotation({ secondaryAction, }: DiffCommentAnnotationProps) { const [localDraftText, setLocalDraftText] = useState(""); - const displayedText = kind === "draft" && !onTextChange ? localDraftText : text; + const isEditingComment = kind === "comment" && edit?.active === true; + const displayedText = isEditingComment + ? edit.text + : kind === "draft" && !onTextChange + ? localDraftText + : text; const trimmedText = displayedText.trim(); + const submit = () => { + if (isEditingComment) { + edit.onSave(trimmedText); + } else { + onComment(trimmedText); + } + }; - if (kind === "comment") { + if (kind === "comment" && !isEditingComment) { return (
); @@ -84,8 +122,12 @@ export function DiffCommentAnnotation({ size="sm" value={displayedText} placeholder={placeholder} - aria-label={`Comment on lines ${rangeLabel}`} - onChange={(event) => (onTextChange ?? setLocalDraftText)(event.target.value)} + aria-label={`${isEditingComment ? "Edit comment" : "Comment"} on lines ${rangeLabel}`} + onChange={(event) => + (isEditingComment ? edit.onChange : (onTextChange ?? setLocalDraftText))( + event.target.value, + ) + } onFocus={(event) => { const end = event.currentTarget.value.length; event.currentTarget.setSelectionRange(end, end); @@ -93,21 +135,33 @@ export function DiffCommentAnnotation({ onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); - onCancel(); + if (isEditingComment) { + edit.onCancel(); + } else { + onCancel(); + } } if (isCommentSubmitShortcut(event, trimmedText, pending)) { event.preventDefault(); - onComment(trimmedText); + submit(); } }} />
- ⌘/Ctrl Enter to send + + ⌘/Ctrl Enter to {isEditingComment ? "save" : "send"} + @@ -122,8 +176,8 @@ export function DiffCommentAnnotation({ {secondaryAction.label} ) : null} -
diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index e3280c99caa..7500fc32d4b 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -3,9 +3,9 @@ import type { ContextMenuOpenContext as TreeContextMenuOpenContext, } from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; -import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; +import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { RotateCw } from "lucide-react"; +import { ChevronsDownUp, ChevronsUpDown, RotateCw } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { Button } from "~/components/ui/button"; @@ -246,6 +246,21 @@ export default function FileBrowserPanel({ search: false, unsafeCSS: TREE_UNSAFE_CSS, }); + const shouldCollapseDirectories = useFileTreeSelector(model, (treeModel) => + entries.some((entry) => { + const item = entry.kind === "directory" ? treeModel.getItem(entry.path) : null; + return item !== null && "isExpanded" in item && item.isExpanded(); + }), + ); + const toggleAllDirectories = () => { + for (const entry of entries) { + if (entry.kind !== "directory") continue; + const item = model.getItem(entry.path); + if (!item || !("isExpanded" in item)) continue; + if (shouldCollapseDirectories) item.collapse(); + else item.expand(); + } + }; const search = useFileTreeSearch(model); const handleSearchValueChange = (value: string) => { if (value.trim().length === 0) { @@ -355,6 +370,26 @@ export default function FileBrowserPanel({ data-surface-subheader > + {entries.some((entry) => entry.kind === "directory") && ( + + + } + > + {shouldCollapseDirectories ? : } + + + {shouldCollapseDirectories ? "Collapse all files" : "Expand all files"} + + + )} ({ + applyPreviewServerSnapshot: vi.fn(), + rememberPreviewUrl: vi.fn(), + isPreviewSupportedInRuntime: () => previewSupported, +})); + +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ openBrowser }) }, +})); + +vi.mock("~/browserHistoryStore", () => ({ + recordVisitForThread: vi.fn(), +})); + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-08-15T00:00:00.000Z", +}; + +afterEach(() => { + previewSupported = true; + vi.clearAllMocks(); +}); + +describe("resolveScriptPreviewUrl", () => { + it("normalises a bare loopback host the way the URL bar does", () => { + expect(resolveScriptPreviewUrl({ previewUrl: "localhost:5173", autoOpenPreview: true })).toBe( + "http://localhost:5173/", + ); + }); + + it("opts out when autoOpenPreview is not set", () => { + expect(resolveScriptPreviewUrl({ previewUrl: "localhost:5173" })).toBeNull(); + expect( + resolveScriptPreviewUrl({ previewUrl: "localhost:5173", autoOpenPreview: false }), + ).toBeNull(); + }); + + it("opts out without a previewUrl", () => { + expect(resolveScriptPreviewUrl({ autoOpenPreview: true })).toBeNull(); + }); + + it("swallows a malformed previewUrl instead of throwing", () => { + expect(resolveScriptPreviewUrl({ previewUrl: "not a url", autoOpenPreview: true })).toBeNull(); + expect( + resolveScriptPreviewUrl({ previewUrl: "file:///etc/passwd", autoOpenPreview: true }), + ).toBeNull(); + }); +}); + +describe("openScriptPreview", () => { + it("opens the panel at the normalised URL", async () => { + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await openScriptPreview({ + threadRef, + script: { previewUrl: "localhost:5173", autoOpenPreview: true }, + openPreview, + }); + + expect(openPreview).toHaveBeenCalledWith({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, url: "http://localhost:5173/" }, + }); + expect(openBrowser).toHaveBeenCalledWith(threadRef, "tab-1"); + }); + + it("does not open the panel for a malformed previewUrl", async () => { + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await openScriptPreview({ + threadRef, + script: { previewUrl: "not a url", autoOpenPreview: true }, + openPreview, + }); + + expect(openPreview).not.toHaveBeenCalled(); + expect(openBrowser).not.toHaveBeenCalled(); + }); + + it("stays out of the way when the runtime has no preview bridge", async () => { + previewSupported = false; + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await openScriptPreview({ + threadRef, + script: { previewUrl: "localhost:5173", autoOpenPreview: true }, + openPreview, + }); + + expect(openPreview).not.toHaveBeenCalled(); + }); + + it("leaves the panel closed when the preview session fails to open", async () => { + const openPreview: OpenPreviewMutation = async () => + AsyncResult.failure(Cause.fail(new Error("unreachable"))); + + await openScriptPreview({ + threadRef, + script: { previewUrl: "localhost:5173", autoOpenPreview: true }, + openPreview, + }); + + expect(openBrowser).not.toHaveBeenCalled(); + }); +}); + +function activity(input: { + id: string; + kind: string; + payload: unknown; +}): OrchestrationThreadActivity { + return { + id: input.id as OrchestrationThreadActivity["id"], + tone: "info", + kind: input.kind, + summary: input.kind, + payload: input.payload, + turnId: null, + createdAt: "2026-08-15T00:00:00.000Z" as OrchestrationThreadActivity["createdAt"], + }; +} + +describe("unfinishedSetupScriptStarts", () => { + it("reports a started run that has not finished", () => { + expect( + unfinishedSetupScriptStarts([ + activity({ + id: "a1", + kind: "setup-script.requested", + payload: { runId: "run-1", scriptId: "dev" }, + }), + activity({ + id: "a2", + kind: "setup-script.started", + payload: { runId: "run-1", scriptId: "dev" }, + }), + ]), + ).toEqual([{ activityId: "a2", scriptId: "dev" }]); + }); + + it("drops a run that already finished, so a replayed thread reopens nothing", () => { + for (const terminalKind of ["setup-script.completed", "setup-script.failed"]) { + expect( + unfinishedSetupScriptStarts([ + activity({ + id: "a2", + kind: "setup-script.started", + payload: { runId: "run-1", scriptId: "dev" }, + }), + activity({ id: "a3", kind: terminalKind, payload: { runId: "run-1", exitCode: 0 } }), + ]), + ).toEqual([]); + } + }); + + it("ignores unrelated activity and payloads without a script id", () => { + expect( + unfinishedSetupScriptStarts([ + activity({ id: "a1", kind: "runtime.info", payload: { runId: "run-1", scriptId: "dev" } }), + activity({ id: "a2", kind: "setup-script.started", payload: { runId: "run-2" } }), + activity({ id: "a3", kind: "setup-script.started", payload: null }), + ]), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/components/preview/openScriptPreview.ts b/apps/web/src/components/preview/openScriptPreview.ts new file mode 100644 index 00000000000..64ff1c37b77 --- /dev/null +++ b/apps/web/src/components/preview/openScriptPreview.ts @@ -0,0 +1,102 @@ +import type { + OrchestrationThreadActivity, + ProjectScript, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; + +import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { recordVisitForThread } from "~/browserHistoryStore"; +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +import { openPreviewSession } from "./openPreviewSession"; + +/** + * The URL an action's preview should open at, or `null` when the action opted + * out or configured something we cannot preview. + * + * Normalisation matches the browser URL bar, so `localhost:5173` works as well + * as a fully-qualified URL. A malformed `previewUrl` resolves to `null` rather + * than throwing: by the time this runs the command is already on its way to + * the terminal, and a bad preview URL must not read as a script failure. + */ +export function resolveScriptPreviewUrl( + script: Pick, +): string | null { + if (script.previewUrl === undefined || script.autoOpenPreview !== true) return null; + try { + return normalizePreviewUrl(script.previewUrl); + } catch { + return null; + } +} + +/** + * Open the preview panel at an action's `previewUrl` now that the action has + * started. Callers fire and forget: an unreachable target must never delay the + * terminal the command was written to. + */ +export async function openScriptPreview(input: { + readonly threadRef: ScopedThreadRef; + readonly script: Pick; + readonly openPreview: OpenPreviewMutation; +}): Promise { + const url = resolveScriptPreviewUrl(input.script); + // The preview panel only exists behind the desktop bridge, which is what the + // "desktop only" badge on these actions is telling the user. + if (url === null || !isPreviewSupportedInRuntime()) return; + + const result = await openPreviewSession({ + openPreview: input.openPreview, + threadRef: input.threadRef, + url, + }); + if (result._tag === "Failure") return; + recordVisitForThread(input.threadRef, url); + useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); +} + +export interface StartedSetupScript { + readonly activityId: string; + readonly scriptId: string; +} + +function setupRunPayload(payload: unknown): { runId: string; scriptId: string | null } | null { + if (typeof payload !== "object" || payload === null) return null; + const record = payload as Record; + if (typeof record.runId !== "string" || record.runId.length === 0) return null; + return { + runId: record.runId, + scriptId: typeof record.scriptId === "string" ? record.scriptId : null, + }; +} + +/** + * `setup-script.started` activities for runs that have not finished yet. + * + * `runOnWorktreeCreate` scripts never reach `runProjectScript` — the server + * runs them — so this activity is the client's only signal that one started. + * Thread activity is replayed on reconnect and refetch, so finished runs are + * dropped here: only a run still in flight is worth opening a preview for. + * Mirrors `deriveUnfinishedSetupRuns` on the server. + */ +export function unfinishedSetupScriptStarts( + activities: ReadonlyArray, +): ReadonlyArray { + const started = new Map(); + for (const activity of activities) { + if (!activity.kind.startsWith("setup-script.")) continue; + const payload = setupRunPayload(activity.payload); + if (payload === null) continue; + if (activity.kind === "setup-script.started") { + if (payload.scriptId !== null) { + started.set(payload.runId, { activityId: activity.id, scriptId: payload.scriptId }); + } + continue; + } + if (activity.kind === "setup-script.requested") continue; + started.delete(payload.runId); + } + return [...started.values()]; +} diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts index 9ead8cfe1c0..5facef4548c 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, previewAutomationDefaultViewport, previewAutomationOpenNeedsOverlay, + resolvePreviewAutomationPresentation, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; @@ -30,6 +31,21 @@ describe("preview automation open readiness", () => { ).toBe(true); }); + it("uses the requested presentation surface while preserving opt-out", () => { + expect(resolvePreviewAutomationPresentation({} as PreviewAutomationOpenInput, undefined)).toBe( + "mini-player", + ); + expect( + resolvePreviewAutomationPresentation({} as PreviewAutomationOpenInput, "right-panel"), + ).toBe("right-panel"); + expect( + resolvePreviewAutomationPresentation( + { open: false } as PreviewAutomationOpenInput, + "right-panel", + ), + ).toBeNull(); + }); + it("does not wait for a desktop overlay when opening an empty tab", () => { expect( previewAutomationOpenNeedsOverlay( @@ -48,6 +64,16 @@ describe("preview automation open readiness", () => { ).toBe(true); }); + it("does not block terminal intent on a background thread's unmounted panel", () => { + expect( + previewAutomationOpenNeedsOverlay( + { url: "http://localhost:5173" } as PreviewAutomationOpenInput, + snapshot({ _tag: "Idle" }), + "right-panel", + ), + ).toBe(false); + }); + it("waits for existing tabs that already have rendered content", () => { expect( previewAutomationOpenNeedsOverlay( diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts index c9d29fd44e1..d6bcd02c533 100644 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts @@ -1,6 +1,7 @@ import { FILL_PREVIEW_VIEWPORT, type PreviewAutomationOpenInput, + type PreviewAutomationPresentation, type PreviewSessionSnapshot, type PreviewViewportSetting, } from "@t3tools/contracts"; @@ -15,10 +16,22 @@ export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): return input.open ?? input.show ?? true; } +export function resolvePreviewAutomationPresentation( + input: PreviewAutomationOpenInput, + presentation: PreviewAutomationPresentation | undefined, +): PreviewAutomationPresentation | null { + return shouldOpenPreviewMiniPlayer(input) ? (presentation ?? "mini-player") : null; +} + export function previewAutomationOpenNeedsOverlay( input: PreviewAutomationOpenInput, snapshot: PreviewSessionSnapshot, + presentation?: PreviewAutomationPresentation, ): boolean { + // A terminal browser-open only needs the panel/tab state accepted. The + // originating terminal may be in a background thread whose webview cannot + // mount until the user returns to it. + if (presentation === "right-panel") return false; return input.url !== undefined || snapshot.navStatus._tag !== "Idle"; } diff --git a/apps/web/src/components/pullRequest/PullRequestChecksTab.test.tsx b/apps/web/src/components/pullRequest/PullRequestChecksTab.test.tsx new file mode 100644 index 00000000000..834e5352768 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestChecksTab.test.tsx @@ -0,0 +1,95 @@ +/** + * What the Checks tab says for the three sets it has to answer for: none reported, a green run, + * and a failing one with a hand-off on it. The component is called as a plain function and its + * tree read for text — it holds no state of its own, so nothing here needs a renderer. + */ +import type { PullRequestCheck } from "@t3tools/contracts"; +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { PullRequestChecksNavButton, PullRequestChecksTab } from "./PullRequestChecksTab"; +import { pullRequestFindingKey } from "./pullRequestDetail.logic"; + +function textOf(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textOf).join(" "); + if (!isValidElement(node)) return ""; + return textOf((node as ReactElement<{ children?: ReactNode }>).props.children); +} + +function check(overrides: Partial = {}): PullRequestCheck { + return { name: "build", status: "success", description: null, url: null, ...overrides }; +} + +function render(props: Parameters[0]): string { + return textOf(PullRequestChecksTab(props)); +} + +describe("PullRequestChecksTab", () => { + it("keeps the empty answer readable, so the tab is worth opening with no checks", () => { + const text = render({ checks: [] }); + expect(text).toContain("No checks reported."); + // No verdict to head a list that has nothing in it. + expect(text).not.toContain("All checks have passed"); + }); + + it("heads the list with the host's rollup and the count behind it", () => { + const text = render({ checks: [check(), check({ name: "lint" })] }); + expect(text).toContain("All checks have passed"); + expect(text).toContain("All checks passed"); + expect(text).toContain("build"); + expect(text).toContain("Passed"); + }); + + it("offers the fix only on the checks that failed", () => { + const failing = check({ name: "typecheck", status: "failure" }); + const text = render({ + checks: [check(), failing], + fixCheckLabel: "Fix", + onFixFinding: () => {}, + }); + expect(text).toContain("Some checks were not successful"); + expect(text).toContain("1 of 2 failing"); + // One button for the one failing run, and none for the run that passed. + expect(text.match(/Fix/g)).toHaveLength(1); + }); + + it("says which check is preparing, and leaves the rest of the buttons alone", () => { + const failing = check({ name: "typecheck", status: "failure" }); + const text = render({ + checks: [failing], + fixCheckLabel: "Fix", + pendingFinding: pullRequestFindingKey({ kind: "check", check: failing }), + onFixFinding: () => {}, + }); + expect(text).toContain("Preparing..."); + expect(text).not.toContain("Fix"); + }); + + it("draws no fix button where there is nowhere to hand the failure to", () => { + const text = render({ checks: [check({ status: "failure" })] }); + expect(text).not.toContain("Fix"); + }); +}); + +describe("PullRequestChecksNavButton", () => { + it("opens the Checks tab from the tab bar summary", () => { + const onSelect = vi.fn(); + const element = PullRequestChecksNavButton({ + checks: [check({ status: "failure" }), check({ name: "lint" })], + onSelect, + }); + const props = element.props as { + readonly onClick: () => void; + readonly className: string; + readonly "aria-label": string; + }; + + expect(element.type).toBe("button"); + expect(props["aria-label"]).toBe("Open checks: 1 of 2 failing"); + expect(props.className).toContain("hover:bg-accent"); + + props.onClick(); + expect(onSelect).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestChecksTab.tsx b/apps/web/src/components/pullRequest/PullRequestChecksTab.tsx new file mode 100644 index 00000000000..a3003eb13d6 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestChecksTab.tsx @@ -0,0 +1,186 @@ +/** + * Every check the host reported for one pull request, as a tab of its own. + * + * It used to be a fold under the summary, where it sat between the description and the + * conversation and was scrolled past more often than it was read. A tab makes the answer to + * "is this green?" one press away wherever the reader is, and gives the list room to say what + * it knows: the rollup rides the top of the scroll box, and the runs read beneath it. + * + * Deliberately hook-free — it draws what the detail already holds, so the panel keeps it mounted + * behind the other tabs for nothing. + */ +import type { PullRequestCheck } from "@t3tools/contracts"; +import { ArrowUpRightIcon, CircleDashedIcon, CircleDotIcon, HammerIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; + +import { Button } from "../ui/button"; +import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; +import { + PullRequestCheckStatusIcon, + pullRequestCheckStatusLabel, + pullRequestChecksState, + pullRequestChecksStatePresentation, + summarizePullRequestChecks, +} from "./pullRequestPresentation"; + +/** A check the reader can act on: the two outcomes that leave something to reproduce. */ +function isFailing(check: PullRequestCheck): boolean { + return check.status === "failure" || check.status === "cancelled"; +} + +/** The tab bar's at-a-glance result doubles as the shortest route into the checks themselves. */ +export function PullRequestChecksNavButton({ + checks, + onSelect, +}: { + checks: ReadonlyArray; + onSelect: () => void; +}) { + const summary = summarizePullRequestChecks(checks); + const state = pullRequestChecksState(checks); + const presentation = state === null ? null : pullRequestChecksStatePresentation(state); + + return ( + + ); +} + +export function PullRequestChecksTab({ + checks, + pendingFinding, + fixCheckLabel = "Fix", + onFixFinding, +}: { + checks: ReadonlyArray; + /** The hand-off currently preparing, if any, so only the check it belongs to says so. */ + pendingFinding?: string | null; + fixCheckLabel?: string; + onFixFinding?: (finding: PullRequestFinding) => void; +}) { + const state = pullRequestChecksState(checks); + // Null for a set nobody can call passed or failed — every run skipped, say. The list still + // reads, but there is no verdict to head it with. + const rollup = state === null ? null : pullRequestChecksStatePresentation(state); + const handoffPending = pendingFinding !== null && pendingFinding !== undefined; + // A host can report the same named run more than once and checks carry no id. Keep the + // occurrence beside the host-provided fields so repeated rows still receive distinct keys. + const keyOccurrences = new Map(); + + const openCheck = (url: string) => { + void readLocalApi()?.shell.openExternal(url); + }; + + return ( +
+ {rollup ? ( + // The verdict rides the top of the scroll box the way the summary's section headings do, + // so a long list of runs never scrolls its own answer out of sight. +
+ {rollup.label} + + + {summarizePullRequestChecks(checks)} + +
+ ) : null} + + {checks.length === 0 ? ( + // The same words the fold under the summary used, given the room a tab has: a reader who + // opened Checks asked a question, and "nothing here" on its own does not answer it. +
+ +

No checks reported.

+

+ Runs appear here as soon as the host reports one for this branch. +

+
+ ) : ( +
    + {checks.map((check) => { + const finding = { kind: "check", check } as const; + const failing = isFailing(check); + const keyBase = `${check.name}:${check.url ?? ""}:${check.status}:${check.description ?? ""}`; + const occurrence = keyOccurrences.get(keyBase) ?? 0; + keyOccurrences.set(keyBase, occurrence + 1); + const rowClassName = + "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"; + const body = ( + <> + + + {check.name} + + + {pullRequestCheckStatusLabel(check.status)} + + + ); + return ( +
  • + {check.url === null ? ( + // Nothing to open, so nothing to press: a row the host gave no link for stays + // out of the tab order rather than sitting in it as a dead button. +
    {body}
    + ) : ( + + )} + {/* Only where there is something to fix. A passing check has no failure to + reproduce, and the button would be an invitation to waste a thread. */} + {onFixFinding && failing ? ( + + ) : null} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 776a4d67136..3608ffa2467 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -73,6 +73,7 @@ import { PullRequestReviewBar } from "./PullRequestReviewBar"; import { isFileDiffCollapsed, isLineInFileDiff, + shouldAutoFoldFileDiff, type DiffFoldOverride, } from "./pullRequestDiff.logic"; import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation"; @@ -211,7 +212,9 @@ export function PullRequestCodeTab({ }) { const { resolvedTheme } = useTheme(); const settings = useClientSettings(); - const [toggledFiles, setToggledFiles] = useState>(() => new Set()); + const [fileFoldOverrides, setFileFoldOverrides] = useState>( + () => new Map(), + ); // A change of any size can carry hundreds of commits, and a menu that long is a scroll rather // than a choice. The rest arrive ten at a time, on request. const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); @@ -248,7 +251,7 @@ export function PullRequestCodeTab({ useEffect(() => { setDraft(null); setSelectedLines(null); - setToggledFiles(new Set()); + setFileFoldOverrides(new Map()); setFoldOverride(null); setVisibleCommitCount(COMMIT_PAGE_SIZE); setOrphansOpen(false); @@ -468,7 +471,12 @@ export function PullRequestCodeTab({ groupAt(anchor.side, anchor.line).draft = true; } - const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); + const collapsed = isFileDiffCollapsed( + fileKey, + foldOverride, + fileFoldOverrides, + shouldAutoFoldFileDiff(fileDiff, groups.size > 0), + ); const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ side: toViewerSide(group.side), @@ -521,7 +529,7 @@ export function PullRequestCodeTab({ foldOverride, pendingComments, placedThreadIds, - toggledFiles, + fileFoldOverrides, ], ); const lineStat = useMemo(() => getDiffLineStat(files), [files]); @@ -573,13 +581,12 @@ export function PullRequestCodeTab({ // these render props, so a fresh function here would recreate every visible file's portal on // every tab re-render (a line-selection drag, a keystroke in the draft, a review-store update). const toggleFile = useCallback( - (fileKey: string) => - setToggledFiles((current) => { - // The override becomes this file's new default the moment it is folded into the set below, - // so nothing has to be re-derived when the reader goes back to choosing one at a time. - const next = new Set(current); - if (next.has(fileKey)) next.delete(fileKey); - else next.add(fileKey); + (fileKey: string, collapsed: boolean) => + setFileFoldOverrides((current) => { + // Store the answer rather than a toggle from the automatic default: adding a draft or + // review annotation can change that default, but must not reverse what the reader chose. + const next = new Map(current); + next.set(fileKey, !collapsed); return next; }), [], @@ -590,7 +597,7 @@ export function PullRequestCodeTab({ // still paging would otherwise bring its next slice in folded, moments after the reader // asked for everything to be open. setFoldOverride(areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys) ? "expanded" : "folded"); - setToggledFiles(new Set()); + setFileFoldOverrides(new Map()); }; // Newest first: the last commit is the one a reader coming back to a change is looking for. @@ -698,7 +705,7 @@ export function PullRequestCodeTab({ className="mr-1 rounded hover:bg-transparent" onClick={(event) => { event.stopPropagation(); - toggleFile(item.id); + toggleFile(item.id, collapsed); }} > {collapsed ? ( @@ -791,11 +798,11 @@ export function PullRequestCodeTab({ // other's conversation. key={`${reference.projectId}#${reference.number}:${thread.id}`} thread={thread} - workspaceRoot={detail.workspaceRoot} + detail={detail} + environmentId={environmentId} canReply={review.reply} canResolve={review.resolve} canReact={detail.capabilities.reactions === true} - environmentId={environmentId} reference={reference} pending={threadPending} fixPending={pendingFinding === pullRequestFindingKey({ kind: "thread", thread })} @@ -1308,7 +1315,7 @@ export function PullRequestCodeTab({ const item = items.find( (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, ); - if (item !== undefined) toggleFile(item.id); + if (item !== undefined) toggleFile(item.id, item.collapsed === true); return; } } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 015371a86d9..027495cb2e0 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -3,6 +3,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" import type { EnvironmentId, PullRequestAction, + PullRequestInvalidationScope, PullRequestMergeMethod, PullRequestUpdateMethod, PullRequestRef, @@ -14,7 +15,6 @@ import { ArrowLeftIcon, ArrowUpRightIcon, BookOpenIcon, - CircleDotIcon, ChevronDownIcon, FileDiffIcon, FolderGit2Icon, @@ -85,11 +85,17 @@ import { } from "../ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; -import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; +import { GithubReferenceThreadContext } from "../chat/githubReferenceLinks"; +import { + PullRequestChecksGhost, + PullRequestDetailGhost, + PullRequestTimelineGhost, +} from "./PullRequestGhosts"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab"; +import { PullRequestChecksNavButton, PullRequestChecksTab } from "./PullRequestChecksTab"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; @@ -125,7 +131,7 @@ import { summarizePullRequestChecks, } from "./pullRequestPresentation"; -type DetailTab = "summary" | "timeline" | "code"; +type DetailTab = "summary" | "checks" | "timeline" | "code"; const ACTION_SUCCESS_LABELS: Record = { merge: "Pull request merged", @@ -182,8 +188,12 @@ const ACTION_FAILURE_HINTS: Record = { const UPDATE_BRANCH_REBASE_FAILURE_HINT = "The host refused it. A rebase stops at the first commit that does not apply cleanly; updating with a merge commit may still work."; +// Checks sits beside the summary rather than inside it: the state of the runs is its own +// question, and folding it under the description put it behind a scroll on the way to the +// conversation. Always offered, checks or none, so the empty answer is findable too. const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ { value: "summary", label: "Summary" }, + { value: "checks", label: "Checks" }, { value: "timeline", label: "Timeline" }, { value: "code", label: "Code" }, ]; @@ -342,17 +352,7 @@ function PullRequestBaseFreshnessWarning({ ); } -export function PullRequestDetailPanel({ - environmentId, - reference, - refreshToken: forcedRefreshToken = 0, - onActed, - onClose, - onStateChange, - context = "page", - chromeVariant = "full", - composerDraftTarget, -}: { +type PullRequestDetailPanelProps = { environmentId: EnvironmentId; reference: PullRequestRef; /** @@ -393,7 +393,36 @@ export function PullRequestDetailPanel({ * land here instead of opening a new thread — the branch is already under the reader's feet. */ composerDraftTarget?: ScopedThreadRef | DraftId; -}) { +}; + +/** + * A `#123` in any body below opens as a tab beside this panel rather than replacing the page. + * Told through a context rather than a prop threaded down through the tabs, and provided out here + * so the panel body below is untouched. + */ +export function PullRequestDetailPanel(props: PullRequestDetailPanelProps) { + // Only where there is a thread to open beside: a draft target names no thread yet. + const target = props.context === "thread" ? props.composerDraftTarget : undefined; + const threadRef = + target !== undefined && typeof target === "object" && "threadId" in target ? target : undefined; + return ( + + + + ); +} + +function PullRequestDetailPanelBody({ + environmentId, + reference, + refreshToken: forcedRefreshToken = 0, + onActed, + onClose, + onStateChange, + context = "page", + chromeVariant = "full", + composerDraftTarget, +}: PullRequestDetailPanelProps) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; const [tab, setTab] = useState("summary"); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); @@ -454,9 +483,10 @@ export function PullRequestDetailPanel({ if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); }, [condensed]); const [mergeMethod, setMergeMethod] = useState("merge"); - const [confirmAction, setConfirmAction] = useState< - "merge" | "close" | "enable-auto-merge" | null - >(null); + const [confirmAction, setConfirmAction] = useState<"merge" | "close" | "enable-auto-merge">( + "merge", + ); + const [confirmOpen, setConfirmOpen] = useState(false); // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself // alone. One at a time whatever the key: they all check the same pull request out. const [handoff, setHandoff] = useState(null); @@ -509,6 +539,17 @@ export function PullRequestDetailPanel({ detailQuery.refresh(); activityQuery.refresh(); }, [activityQuery.refresh, detailQuery.refresh]); + const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const refreshDetailFromHost = useCallback( + async (scope: PullRequestInvalidationScope) => { + await invalidate({ + environmentId, + input: { reference, ...(scope === "detail" ? { scope } : {}) }, + }); + refreshDetail(); + }, + [environmentId, invalidate, reference, refreshDetail], + ); useEffect(() => { if (!detail) return; onStateChange?.({ @@ -521,22 +562,21 @@ export function PullRequestDetailPanel({ }, [detail, onStateChange]); // A pull request changes while it is open in front of somebody — a push lands, a check // finishes, a review arrives — so the panel reads it again on the way back to the window and - // while a reader sits on it. Keyed by the pull request rather than by the panel, because this - // one panel shows a different pull request every time it is opened. - useLiveRefresh(refreshDetail, { + // while a reader sits on it. These reads invalidate first: an ordinary read is allowed to + // return the server's last answer while it revalidates in the background, which would leave + // the visible panel one refresh behind. Keyed by the pull request rather than by the panel, + // because this one panel shows a different pull request every time it is opened. + useLiveRefresh(() => void refreshDetailFromHost("detail"), { key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`, }); - // The button, on the other hand, goes around the server's cache rather than through it: it is - // the answer for a reader who can see that what they are looking at is behind. The - // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run - // and at worst answer from it. - const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + // Manual refresh also advances the Code tab's token. Live refresh deliberately stops at the + // detail and activity reads: keeping the potentially large diff current is a separate policy, + // while a reader asking for everything on screen expects the patch to be included too. const [refreshToken, setRefreshToken] = useState(0); const refreshFromHost = useCallback(async () => { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); + await refreshDetailFromHost("all"); setRefreshToken((token) => token + 1); - }, [environmentId, invalidate, reference, refreshDetail]); + }, [refreshDetailFromHost]); // A refresh asked for by the page: the detail, and through the token below, the diff with it. const appliedForcedToken = useRef(forcedRefreshToken); useEffect(() => { @@ -1245,7 +1285,10 @@ export function PullRequestDetailPanel({ allowedMergeMethods.length > 0 ? ( setConfirmAction("enable-auto-merge")} + onClick={() => { + setConfirmAction("enable-auto-merge"); + setConfirmOpen(true); + }} > Enable auto-merge @@ -1311,7 +1354,10 @@ export function PullRequestDetailPanel({ setConfirmAction("close")} + onClick={() => { + setConfirmAction("close"); + setConfirmOpen(true); + }} > Close pull request @@ -1401,7 +1447,10 @@ export function PullRequestDetailPanel({ @@ -1694,19 +1743,10 @@ export function PullRequestDetailPanel({ ))} {tab === "summary" ? ( - - {/* The rollup icon opens the checks behind the summary; with none reported - there is nothing to open, so the plain glyph stays. */} - {detail && checksState !== null ? ( - - ) : ( - - )} - {checksSummary} - + setTab("checks")} + /> ) : tab === "timeline" ? (
+ ) : tab === "checks" ? ( + ) : tab === "code" ? ( ) : ( @@ -1831,12 +1873,21 @@ export function PullRequestDetailPanel({ activityError={activityError} pendingFinding={handoff} fixFindingLabel={handoffLabels.fixFinding} - fixCheckLabel={handoffLabels.fixCheck} onFixFinding={startFixFinding} onRefresh={refreshDetail} />
) : null} + {mountedTabs.has("checks") ? ( +
+ +
+ ) : null} {mountedTabs.has("timeline") ? (
{activityPending ? ( @@ -1848,8 +1899,8 @@ export function PullRequestDetailPanel({ /> ) : ( - !open && setConfirmAction(null)} - > + @@ -1914,12 +1962,11 @@ export function PullRequestDetailPanel({ variant={confirmAction === "close" ? "destructive" : "default"} disabled={actionPending} onClick={() => { - const action = confirmAction; - setConfirmAction(null); - if (action === "merge") void perform("merge", selectedMergeMethod); - if (action === "enable-auto-merge") + setConfirmOpen(false); + if (confirmAction === "merge") void perform("merge", selectedMergeMethod); + if (confirmAction === "enable-auto-merge") void perform("enable-auto-merge", selectedMergeMethod); - if (action === "close") void perform("close"); + if (confirmAction === "close") void perform("close"); }} > {confirmAction === "merge" diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 09b79cf340e..3dc3ebdfdec 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -90,6 +90,28 @@ export function PullRequestDetailGhost() { ); } +/** The checks tab's own shape: the rollup row, then a status dot, a name and a verdict to each. */ +export function PullRequestChecksGhost({ rows = 5 }: { rows?: number }) { + return ( +
+
+ + + +
+
+ {Array.from({ length: rows }, (_, index) => ( +
+ + + +
+ ))} +
+
+ ); +} + /** People-shaped: an avatar and a name, in the reviewer picker's own row height. */ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { return ( diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 46aa44dc128..c1170c59c4e 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,9 +1,61 @@ -import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; +import type { EnvironmentId, PullRequestDetailView } from "@t3tools/contracts"; +import { ExternalLinkIcon, LoaderCircleIcon, PaperclipIcon, PlayIcon } from "lucide-react"; +import { type ComponentPropsWithoutRef, useCallback, useContext, useMemo, useState } from "react"; +import type { ExtraProps } from "react-markdown"; +import { useAssetUrlState } from "~/assets/assetUrls"; import { cn } from "~/lib/utils"; import ChatMarkdown from "../ChatMarkdown"; -import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { + GithubReferenceThreadContext, + type GithubReferenceSurface, +} from "../chat/githubReferenceLinks"; +import { + resolvePullRequestRepositoryImagePath, + splitPullRequestBody, +} from "./pullRequestMarkdown.logic"; + +function PullRequestRepositoryImage({ + detail, + environmentId, + path, + alt, + ...props +}: Omit, "src"> & { + detail: PullRequestDetailView; + environmentId: EnvironmentId; + path: string; +}) { + const assetUrl = useAssetUrlState(environmentId, { + _tag: "pull-request-file", + projectId: detail.projectId, + repository: detail.repository, + number: detail.number, + path, + }); + const [failedUrl, setFailedUrl] = useState(null); + + if (assetUrl._tag === "Loading") { + return ( + + + Loading image… + + ); + } + if (assetUrl._tag === "Failure" || failedUrl === assetUrl.url) { + return ( + + Image unavailable. + + ); + } + return {alt} setFailedUrl(assetUrl.url)} />; +} /** * A pull request body, rendered with the app's markdown renderer plus a card for each upload @@ -18,19 +70,79 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; */ export function PullRequestMarkdown({ text, - cwd, + detail, + environmentId, className, }: { text: string; - cwd: string; + detail: PullRequestDetailView; + environmentId: EnvironmentId; className?: string; }) { + const imageRenderer = useCallback( + ({ node: _node, src, ...props }: ComponentPropsWithoutRef<"img"> & ExtraProps) => { + const path = src + ? resolvePullRequestRepositoryImagePath(src, { + provider: detail.provider, + repository: detail.repository, + url: detail.url, + headBranch: detail.headBranch, + }) + : null; + return path === null ? ( + + ) : ( + + ); + }, + [detail, environmentId], + ); + // The host comes from the change request's own address, so an Enterprise install is read as + // itself rather than as github.com. + const surfaceThreadRef = useContext(GithubReferenceThreadContext); + const referenceContext = useMemo((): GithubReferenceSurface | undefined => { + if (detail.provider !== "github") return undefined; + let host: string; + try { + host = new URL(detail.url).hostname.toLowerCase(); + } catch { + return undefined; + } + return { + host, + repository: detail.repository, + environmentId, + cwd: detail.workspaceRoot, + threadRef: surfaceThreadRef, + }; + }, [ + detail.provider, + detail.repository, + detail.url, + detail.workspaceRoot, + environmentId, + surfaceThreadRef, + ]); + const segments = splitPullRequestBody(text); return (
{segments.map((segment) => { if (segment.kind === "markdown") { - return ; + return ( + + ); } const isVideo = segment.media === "video"; const Icon = isVideo ? PlayIcon : PaperclipIcon; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx index f0145c059c0..17f6579e4a4 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx @@ -1,3 +1,4 @@ +import type { EnvironmentId, PullRequestDetailView } from "@t3tools/contracts"; import { useState } from "react"; import { cn } from "~/lib/utils"; @@ -16,7 +17,8 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; */ export function PullRequestMarkdownEditor({ value, - cwd, + detail, + environmentId, placeholder, label, saving, @@ -26,7 +28,8 @@ export function PullRequestMarkdownEditor({ onCancel, }: { readonly value: string; - readonly cwd: string; + readonly detail: PullRequestDetailView; + readonly environmentId: EnvironmentId; readonly placeholder?: string | undefined; readonly label: string; readonly saving: boolean; @@ -81,7 +84,7 @@ export function PullRequestMarkdownEditor({ {empty ? (

Nothing to preview.

) : ( - + )}
) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index 47f59240ac5..7c3a33764de 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -4,6 +4,7 @@ */ import type { EnvironmentId, + PullRequestDetailView, PullRequestRef, PullRequestReviewThread, PullRequestThreadComment, @@ -87,11 +88,11 @@ export function PendingReviewCommentCard({ /** A conversation already on the host, with whatever this host lets the reader do to it. */ export function ReviewThreadCard({ thread, - workspaceRoot, + detail, + environmentId, canReply, canResolve, canReact, - environmentId, reference, pending, fixPending, @@ -104,11 +105,11 @@ export function ReviewThreadCard({ onReacted, }: { thread: PullRequestReviewThread; - workspaceRoot: string; + detail: PullRequestDetailView; + environmentId: EnvironmentId; canReply: boolean; canResolve: boolean; canReact: boolean; - environmentId: EnvironmentId; reference: PullRequestRef; pending: boolean; /** True while this thread's own hand-off is preparing, so only its button says so. */ @@ -217,7 +218,8 @@ export function ReviewThreadCard({ void saveEdit(comment.id, body)} @@ -228,7 +230,8 @@ export function ReviewThreadCard({ {canEditComment(comment) ? ( - {/* Only where there is something to fix. A passing check has no failure to - reproduce, and the button would be an invitation to waste a thread. */} - {onFixFinding && failing ? ( - - ) : null} -
- ); - })} - - )} - -
void; } -function TimelineBody({ body, markdown, cwd }: { body: string; markdown: boolean; cwd: string }) { +function TimelineBody({ + body, + markdown, + detail, + environmentId, +}: { + body: string; + markdown: boolean; + detail: PullRequestDetailView; + environmentId: EnvironmentId; +}) { return (
{markdown ? ( - + ) : (

{body}

)} @@ -152,14 +162,14 @@ function OpenOnHostButton({ url, onOpen }: { url: string | null; onOpen: (url: s function ConversationCard({ event, editable, - cwd, + detail, onOpen, reactions, }: { event: PullRequestTimelineEvent; /** The remark behind this entry, only where this reader may rewrite it. */ editable: PullRequestComment | null; - cwd: string; + detail: PullRequestDetailView; onOpen: (url: string) => void; reactions: ReactionSurface; }) { @@ -225,7 +235,8 @@ function ConversationCard({
void save(body)} @@ -234,7 +245,12 @@ function ConversationCard({
) : event.body ? (
- +
) : null} {reactions.canReact || event.reactions.length > 0 ? ( @@ -265,13 +281,13 @@ function uniqueConversationActors(events: ReadonlyArray; editable: ReadonlyMap; - cwd: string; + detail: PullRequestDetailView; onOpen: (url: string) => void; reactions: ReactionSurface; }) { @@ -324,7 +340,7 @@ function ConversationGroup({ key={`${reactions.reference.projectId}#${reactions.reference.number}:${event.id}`} event={event} editable={editable.get(event.id) ?? null} - cwd={cwd} + detail={detail} onOpen={onOpen} reactions={reactions} /> @@ -458,7 +474,7 @@ export function PullRequestTimelineTab({ key={`comments:${row.events[0]?.id ?? "empty"}`} events={row.events} editable={editable} - cwd={detail.workspaceRoot} + detail={detail} onOpen={openOnHost} reactions={reactions} /> diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index b39cfd9ff1b..ea88ed39339 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -1,7 +1,12 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { isFileDiffCollapsed, isLineInFileDiff } from "./pullRequestDiff.logic"; +import { + isFileDiffCollapsed, + isLineInFileDiff, + PULL_REQUEST_DIFF_AUTO_FOLD_LINE_THRESHOLD, + shouldAutoFoldFileDiff, +} from "./pullRequestDiff.logic"; /** Only the hunk ranges matter here; the viewer fills the rest in when it renders. */ function fileWithHunks( @@ -48,34 +53,76 @@ describe("isLineInFileDiff", () => { }); describe("isFileDiffCollapsed", () => { - const NO_TOGGLES: ReadonlySet = new Set(); + const NO_OVERRIDES: ReadonlyMap = new Map(); - it("folds every file before the reader has touched anything", () => { - expect(isFileDiffCollapsed("a.ts", null, NO_TOGGLES)).toBe(true); - expect(isFileDiffCollapsed("b.ts", null, NO_TOGGLES)).toBe(true); + it("opens every file before the reader has touched anything", () => { + expect(isFileDiffCollapsed("a.ts", null, NO_OVERRIDES)).toBe(false); + expect(isFileDiffCollapsed("b.ts", null, NO_OVERRIDES)).toBe(false); }); it("opens every file once the toolbar has asked for it", () => { - // Pressing the toolbar clears the reader's own toggles, which is why the set is empty here. - expect(isFileDiffCollapsed("a.ts", "expanded", NO_TOGGLES)).toBe(false); - expect(isFileDiffCollapsed("b.ts", "expanded", NO_TOGGLES)).toBe(false); + // Pressing the toolbar clears the reader's per-file overrides, which is why the map is empty. + expect(isFileDiffCollapsed("a.ts", "expanded", NO_OVERRIDES)).toBe(false); + expect(isFileDiffCollapsed("b.ts", "expanded", NO_OVERRIDES)).toBe(false); }); it("folds every file again on the second press", () => { - expect(isFileDiffCollapsed("a.ts", "folded", NO_TOGGLES)).toBe(true); - expect(isFileDiffCollapsed("b.ts", "folded", NO_TOGGLES)).toBe(true); + expect(isFileDiffCollapsed("a.ts", "folded", NO_OVERRIDES)).toBe(true); + expect(isFileDiffCollapsed("b.ts", "folded", NO_OVERRIDES)).toBe(true); }); - it("keeps a file the reader opened open as the next slice arrives", () => { - // The file keys grow with every slice, so the answer for one already open must not depend on - // how many of them there are by then. - const toggled = new Set(["b.ts"]); - expect(isFileDiffCollapsed("b.ts", null, toggled)).toBe(false); - expect(isFileDiffCollapsed("c.ts", null, toggled)).toBe(true); + it("keeps a file the reader folded closed as the next slice arrives", () => { + // The file keys grow with every slice, so the answer for one already folded must not depend + // on how many of them there are by then. + const overrides = new Map([["b.ts", true]]); + expect(isFileDiffCollapsed("b.ts", null, overrides)).toBe(true); + expect(isFileDiffCollapsed("c.ts", null, overrides)).toBe(false); }); - it("still answers to a toggle after either toolbar press", () => { - expect(isFileDiffCollapsed("a.ts", "expanded", new Set(["a.ts"]))).toBe(true); - expect(isFileDiffCollapsed("a.ts", "folded", new Set(["a.ts"]))).toBe(false); + it("keeps a later per-file override ahead of either toolbar choice", () => { + expect(isFileDiffCollapsed("a.ts", "expanded", new Map([["a.ts", true]]))).toBe(true); + expect(isFileDiffCollapsed("a.ts", "folded", new Map([["a.ts", false]]))).toBe(false); + }); + + it("starts an oversized file folded until the reader opens it", () => { + expect(isFileDiffCollapsed("large.ts", null, NO_OVERRIDES, true)).toBe(true); + expect(isFileDiffCollapsed("large.ts", null, new Map([["large.ts", false]]), true)).toBe(false); + }); + + it("lets the toolbar override an oversized file's automatic fold", () => { + expect(isFileDiffCollapsed("large.ts", "expanded", NO_OVERRIDES, true)).toBe(false); + expect(isFileDiffCollapsed("small.ts", "folded", NO_OVERRIDES, false)).toBe(true); + }); + + it("does not reverse a manual choice when the automatic default changes", () => { + const opened = new Map([["large.ts", false]]); + expect(isFileDiffCollapsed("large.ts", null, opened, true)).toBe(false); + expect(isFileDiffCollapsed("large.ts", null, opened, false)).toBe(false); + }); +}); + +describe("shouldAutoFoldFileDiff", () => { + const fileWithLineCount = (unifiedLineCount: number) => + ({ unifiedLineCount }) as FileDiffMetadata; + + it("folds only files taller than the automatic limit", () => { + expect( + shouldAutoFoldFileDiff(fileWithLineCount(PULL_REQUEST_DIFF_AUTO_FOLD_LINE_THRESHOLD), false), + ).toBe(false); + expect( + shouldAutoFoldFileDiff( + fileWithLineCount(PULL_REQUEST_DIFF_AUTO_FOLD_LINE_THRESHOLD + 1), + false, + ), + ).toBe(true); + }); + + it("keeps an oversized file open when it carries a review annotation", () => { + expect( + shouldAutoFoldFileDiff( + fileWithLineCount(PULL_REQUEST_DIFF_AUTO_FOLD_LINE_THRESHOLD + 1), + true, + ), + ).toBe(false); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index b3c19c4fe9c..c98d9243c4e 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -24,21 +24,30 @@ export function isLineInFileDiff( /** What the toolbar last asked of every file at once, null being the reader asking nothing yet. */ export type DiffFoldOverride = "expanded" | "folded" | null; +/** Past this height, one file stops being a useful part of the surrounding scroll. */ +export const PULL_REQUEST_DIFF_AUTO_FOLD_LINE_THRESHOLD = 1_200; + +/** Oversized files stay out of the way, unless a conversation gives the reader a target. */ +export function shouldAutoFoldFileDiff(file: FileDiffMetadata, hasAnnotations: boolean): boolean { + return !hasAnnotations && file.unifiedLineCount > PULL_REQUEST_DIFF_AUTO_FOLD_LINE_THRESHOLD; +} + /** * Whether a file is drawn folded. * - * A diff arrives a slice at a time, so the reader's own choices are kept as the difference from - * what the toolbar last said rather than as the set of folded files: a file that has not loaded - * yet cannot be in a set, and would otherwise land expanded moments after the reader folded - * everything. Folded is the starting point whatever the change's size, because laying out every - * file of it costs the reader the seconds before the tab is usable and buries the file they came - * for among the ones they did not. + * A diff arrives a slice at a time, so the toolbar's choice is kept as the default that later + * files inherit. Per-file choices are explicit answers: an annotation can change an oversized + * file's automatic default without reversing what the reader already chose. Ordinary files start + * open while individually oversized files start folded. */ export function isFileDiffCollapsed( fileKey: string, foldOverride: DiffFoldOverride, - toggledFileKeys: ReadonlySet, + fileFoldOverrides: ReadonlyMap, + autoFolded = false, ): boolean { - const foldedByDefault = foldOverride !== "expanded"; - return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault; + const fileOverride = fileFoldOverrides.get(fileKey); + if (fileOverride !== undefined) return fileOverride; + const foldedByDefault = foldOverride === null ? autoFolded : foldOverride === "folded"; + return foldedByDefault; } diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts index 5779909a6a6..3550e4dd26c 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts @@ -1,6 +1,52 @@ import { describe, expect, it } from "vite-plus/test"; -import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { + resolvePullRequestRepositoryImagePath, + splitPullRequestBody, +} from "./pullRequestMarkdown.logic"; + +const GITHUB_CONTEXT = { + provider: "github", + repository: "incognitojam/timeline", + url: "https://github.com/incognitojam/timeline/pull/753", + headBranch: "add-self-contact-flag", +}; + +describe("pull request repository images", () => { + it("resolves a GitHub blob image at the pull request branch", () => { + expect( + resolvePullRequestRepositoryImagePath( + "https://github.com/incognitojam/timeline/blob/add-self-contact-flag/web/e2e/contact-detail.png?raw=1", + GITHUB_CONTEXT, + ), + ).toBe("web/e2e/contact-detail.png"); + }); + + it("resolves relative and commit-pinned repository images", () => { + expect(resolvePullRequestRepositoryImagePath("./docs/screenshot.png", GITHUB_CONTEXT)).toBe( + "docs/screenshot.png", + ); + expect( + resolvePullRequestRepositoryImagePath( + "https://github.com/incognitojam/timeline/blob/f4913679f3b33ba1848c6bf4934228e9eb71b30f/docs/screenshot.png", + GITHUB_CONTEXT, + ), + ).toBe("docs/screenshot.png"); + }); + + it("leaves external, cross-repository, and unsafe paths alone", () => { + expect( + resolvePullRequestRepositoryImagePath("https://example.com/screenshot.png", GITHUB_CONTEXT), + ).toBeNull(); + expect( + resolvePullRequestRepositoryImagePath( + "https://github.com/acme/other/blob/add-self-contact-flag/screenshot.png", + GITHUB_CONTEXT, + ), + ).toBeNull(); + expect(resolvePullRequestRepositoryImagePath("../secret.png", GITHUB_CONTEXT)).toBeNull(); + }); +}); describe("pull request body segmentation", () => { it("keeps a plain body as a single markdown run", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index e322b595a18..c25f642a2e5 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -14,6 +14,100 @@ export type PullRequestBodySegment = readonly media: "video" | "unknown"; }; +export interface PullRequestImageContext { + readonly provider: string; + readonly repository: string; + readonly url: string; + readonly headBranch: string; +} + +function normalizeRepositoryPath(value: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + return null; + } + if (decoded.startsWith("/") || decoded.includes("\\")) return null; + const segments = decoded.split("/").filter((segment) => segment !== "." && segment !== ""); + if (segments.length === 0 || segments.some((segment) => segment === "..")) return null; + const path = segments.join("/"); + return path.length <= 1_024 ? path : null; +} + +function decodedUrlSegments(url: URL): string[] | null { + try { + return url.pathname + .split("/") + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)); + } catch { + return null; + } +} + +/** + * Finds the repository path behind an image in GitHub-flavoured pull request markdown. Private + * repository blob URLs cannot load as cross-site images: the browser does not carry the CLI's + * authentication. The caller exchanges this path for a short-lived host URL through the server. + */ +export function resolvePullRequestRepositoryImagePath( + source: string, + context: PullRequestImageContext, +): string | null { + if (context.provider !== "github") return null; + + // A relative image in a pull request body names a file at the pull request head. + if (!/^(?:[a-z][a-z\d+.-]*:|\/\/|\/)/iu.test(source)) { + return normalizeRepositoryPath(source.split(/[?#]/u, 1)[0] ?? ""); + } + + let sourceUrl: URL; + let pullRequestUrl: URL; + try { + sourceUrl = new URL(source); + pullRequestUrl = new URL(context.url); + } catch { + return null; + } + if (sourceUrl.protocol !== "https:" && sourceUrl.protocol !== "http:") return null; + + const repository = context.repository.split("/").filter(Boolean); + const head = context.headBranch.split("/").filter(Boolean); + const segments = decodedUrlSegments(sourceUrl); + if (repository.length !== 2 || head.length === 0 || segments === null) return null; + + let tail: string[] | null = null; + if (sourceUrl.host === pullRequestUrl.host) { + const markerIndex = repository.length; + const marker = segments[markerIndex]; + if ( + segments.slice(0, repository.length).join("/") !== repository.join("/") || + (marker !== "blob" && marker !== "raw") + ) { + return null; + } + const afterMarker = segments.slice(markerIndex + 1); + const matchesHead = head.every((segment, index) => afterMarker[index] === segment); + if (matchesHead) { + tail = afterMarker.slice(head.length); + } else if (/^[a-f\d]{7,64}$/iu.test(afterMarker[0] ?? "")) { + // Authors sometimes pin a screenshot to the exact pull request commit. + tail = afterMarker.slice(1); + } + } else if ( + pullRequestUrl.host === "github.com" && + sourceUrl.host === "raw.githubusercontent.com" + ) { + const matchesRepository = repository.every((segment, index) => segments[index] === segment); + const afterRepository = segments.slice(repository.length); + const matchesHead = head.every((segment, index) => afterRepository[index] === segment); + if (matchesRepository && matchesHead) tail = afterRepository.slice(head.length); + } + + return tail === null ? null : normalizeRepositoryPath(tail.join("/")); +} + const FENCE_PATTERN = /^\s{0,3}((?:`{3,})|(?:~{3,}))(.*)$/u; /** * How far a `